feat(remote): add desktop SSH remote instances lifecycle and UX (#515)

* feat(remote): add desktop SSH remote instances lifecycle and settings UX

* fix(remote): cancel SSH modal connect and correct desktop runtime detection

* fix(remote): unblock ssh auth flow and disconnect on instance removal

* fix(remote-ssh): harden remote lifecycle, auth probing, and port forwarding reliability

Improve SSH remote stability by correctly handling authenticated external probes, accepting ControlMaster handoff behavior, making reconnect detection tunnel-aware, and applying extra forwards through the shared master connection with local listener validation.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
shekohex
2026-02-26 20:26:12 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 5250a20578
commit 27321d454b
12 changed files with 6335 additions and 128 deletions
+242 -80
View File
@@ -1,19 +1,29 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod remote_ssh;
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose, Engine as _};
use remote_ssh::DesktopSshManagerState;
use serde::{Deserialize, Serialize};
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
};
use std::{
net::TcpListener,
process::Command,
sync::{atomic::{AtomicU64, Ordering}, Mutex},
sync::{
atomic::{AtomicU64, Ordering},
Mutex,
},
time::Duration,
};
use std::{collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}};
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri::utils::config::BackgroundThrottlingPolicy;
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
/// Global counter for generating unique window labels.
static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1);
@@ -146,8 +156,16 @@ fn build_macos_menu<R: tauri::Runtime>(
.map(|state| *state.auto_worktree.lock().expect("menu state mutex"))
.unwrap_or(false);
let new_session_shortcut = if auto_worktree { "Cmd+Shift+N" } else { "Cmd+N" };
let new_worktree_shortcut = if auto_worktree { "Cmd+N" } else { "Cmd+Shift+N" };
let new_session_shortcut = if auto_worktree {
"Cmd+Shift+N"
} else {
"Cmd+N"
};
let new_worktree_shortcut = if auto_worktree {
"Cmd+N"
} else {
"Cmd+Shift+N"
};
let about = MenuItem::with_id(
app,
@@ -211,8 +229,13 @@ fn build_macos_menu<R: tauri::Runtime>(
MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?;
let open_diff_tab =
MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?;
let open_files_tab =
MenuItem::with_id(app, MENU_ITEM_OPEN_FILES_TAB_ID, "Files", true, None::<&str>)?;
let open_files_tab = MenuItem::with_id(
app,
MENU_ITEM_OPEN_FILES_TAB_ID,
"Files",
true,
None::<&str>,
)?;
let open_terminal_tab = MenuItem::with_id(
app,
MENU_ITEM_OPEN_TERMINAL_TAB_ID,
@@ -222,12 +245,27 @@ fn build_macos_menu<R: tauri::Runtime>(
)?;
let copy = MenuItem::with_id(app, MENU_ITEM_COPY_ID, "Copy", true, Some("Cmd+C"))?;
let theme_light =
MenuItem::with_id(app, MENU_ITEM_THEME_LIGHT_ID, "Light Theme", true, None::<&str>)?;
let theme_dark =
MenuItem::with_id(app, MENU_ITEM_THEME_DARK_ID, "Dark Theme", true, None::<&str>)?;
let theme_system =
MenuItem::with_id(app, MENU_ITEM_THEME_SYSTEM_ID, "System Theme", true, None::<&str>)?;
let theme_light = MenuItem::with_id(
app,
MENU_ITEM_THEME_LIGHT_ID,
"Light Theme",
true,
None::<&str>,
)?;
let theme_dark = MenuItem::with_id(
app,
MENU_ITEM_THEME_DARK_ID,
"Dark Theme",
true,
None::<&str>,
)?;
let theme_system = MenuItem::with_id(
app,
MENU_ITEM_THEME_SYSTEM_ID,
"System Theme",
true,
None::<&str>,
)?;
let toggle_sidebar = MenuItem::with_id(
app,
@@ -261,8 +299,13 @@ fn build_macos_menu<R: tauri::Runtime>(
Some("Cmd+Shift+L"),
)?;
let report_bug =
MenuItem::with_id(app, MENU_ITEM_REPORT_BUG_ID, "Report a Bug", true, None::<&str>)?;
let report_bug = MenuItem::with_id(
app,
MENU_ITEM_REPORT_BUG_ID,
"Report a Bug",
true,
None::<&str>,
)?;
let request_feature = MenuItem::with_id(
app,
MENU_ITEM_REQUEST_FEATURE_ID,
@@ -270,14 +313,28 @@ fn build_macos_menu<R: tauri::Runtime>(
true,
None::<&str>,
)?;
let join_discord =
MenuItem::with_id(app, MENU_ITEM_JOIN_DISCORD_ID, "Join Discord", true, None::<&str>)?;
let join_discord = MenuItem::with_id(
app,
MENU_ITEM_JOIN_DISCORD_ID,
"Join Discord",
true,
None::<&str>,
)?;
let clear_cache =
MenuItem::with_id(app, MENU_ITEM_CLEAR_CACHE_ID, "Clear Cache", true, None::<&str>)?;
let clear_cache = MenuItem::with_id(
app,
MENU_ITEM_CLEAR_CACHE_ID,
"Clear Cache",
true,
None::<&str>,
)?;
let theme_submenu =
Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark, &theme_system])?;
let theme_submenu = Submenu::with_items(
app,
"Theme",
true,
&[&theme_light, &theme_dark, &theme_system],
)?;
let window_menu = Submenu::with_id_and_items(
app,
@@ -435,7 +492,10 @@ fn desktop_clear_cache(app: tauri::AppHandle) -> Result<(), String> {
}
if !failures.is_empty() {
return Err(format!("Failed to clear browsing data for some windows: {}", failures.join("; ")));
return Err(format!(
"Failed to clear browsing data for some windows: {}",
failures.join("; ")
));
}
// Reload all windows after clearing persisted browsing data so in-memory state is reset too.
@@ -461,7 +521,11 @@ fn desktop_open_path(path: String, app: Option<String>) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let mut command = Command::new("open");
if let Some(app_name) = app.as_ref().map(|value| value.trim()).filter(|value| !value.is_empty()) {
if let Some(app_name) = app
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
command.arg("-a").arg(app_name);
}
command.arg(trimmed);
@@ -578,9 +642,11 @@ fn desktop_get_installed_apps(
let cached_icon_map: HashMap<String, String> = HashMap::new();
tauri::async_runtime::spawn_blocking(move || {
log::info!("[open-in] scan start: {} candidates", app_names.len());
let refreshed = build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh);
let refreshed =
build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh);
if log::log_enabled!(log::Level::Info) {
let names: Vec<String> = refreshed.iter().map(|entry| entry.name.clone()).collect();
let names: Vec<String> =
refreshed.iter().map(|entry| entry.name.clone()).collect();
log::info!("[open-in] scan apps: {:?}", names);
}
log::info!("[open-in] scan done: {} installed", refreshed.len());
@@ -603,9 +669,11 @@ fn desktop_get_installed_apps(
let cached_icon_map: HashMap<String, String> = HashMap::new();
tauri::async_runtime::spawn_blocking(move || {
log::info!("[open-in] scan start: {} candidates", app_names.len());
let refreshed = build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh);
let refreshed =
build_installed_apps(&app_names, &cached_icon_map, force_icon_refresh);
if log::log_enabled!(log::Level::Info) {
let names: Vec<String> = refreshed.iter().map(|entry| entry.name.clone()).collect();
let names: Vec<String> =
refreshed.iter().map(|entry| entry.name.clone()).collect();
log::info!("[open-in] scan apps: {:?}", names);
}
log::info!("[open-in] scan done: {} installed", refreshed.len());
@@ -714,7 +782,10 @@ fn resolve_app_bundle_path(app_name: &str) -> Option<PathBuf> {
}
}
if let Ok(output) = Command::new("mdfind").args(["-name", &bundle_name]).output() {
if let Ok(output) = Command::new("mdfind")
.args(["-name", &bundle_name])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
@@ -735,9 +806,10 @@ fn resolve_app_bundle_path(app_name: &str) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
fn installed_apps_cache_path() -> PathBuf {
let home = env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"));
home
.join(".config")
let home = env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
home.join(".config")
.join("openchamber")
.join(INSTALLED_APPS_CACHE_FILE)
}
@@ -776,10 +848,10 @@ fn build_installed_apps(
let icon_data_url = if force_icon_refresh {
resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed))
} else {
cached_icon_map
.get(trimmed)
.cloned()
.or_else(|| resolve_app_icon_path(&app_path).and_then(|icon| icon_to_data_url(&icon, trimmed)))
cached_icon_map.get(trimmed).cloned().or_else(|| {
resolve_app_icon_path(&app_path)
.and_then(|icon| icon_to_data_url(&icon, trimmed))
})
};
results.push(InstalledAppInfo {
name: trimmed.to_string(),
@@ -807,17 +879,19 @@ fn resolve_app_icon_path(app_path: &Path) -> Option<PathBuf> {
}
if let Some(icon_file) = read_bundle_icon_file(app_path) {
let icon_path = app_path
.join("Contents")
.join("Resources")
.join(&icon_file);
let icon_path = app_path.join("Contents").join("Resources").join(&icon_file);
if icon_path.exists() {
return Some(icon_path);
}
}
if let Ok(output) = Command::new("mdls")
.args(["-name", "kMDItemIconFile", "-raw", &app_path.to_string_lossy()])
.args([
"-name",
"kMDItemIconFile",
"-raw",
&app_path.to_string_lossy(),
])
.output()
{
if output.status.success() {
@@ -829,10 +903,7 @@ fn resolve_app_icon_path(app_path: &Path) -> Option<PathBuf> {
} else {
format!("{icon_name}.icns")
};
let icon_path = app_path
.join("Contents")
.join("Resources")
.join(icon_file);
let icon_path = app_path.join("Contents").join("Resources").join(icon_file);
if icon_path.exists() {
return Some(icon_path);
}
@@ -949,7 +1020,10 @@ fn is_app_bundle_installed(bundle_name: &str) -> bool {
let system_app_path = format!("/System/Applications/{bundle_name}");
let utilities_path = format!("/System/Applications/Utilities/{bundle_name}");
if Path::new(&app_path).exists() || Path::new(&system_app_path).exists() || Path::new(&utilities_path).exists() {
if Path::new(&app_path).exists()
|| Path::new(&system_app_path).exists()
|| Path::new(&utilities_path).exists()
{
return true;
}
@@ -1136,7 +1210,13 @@ fn read_desktop_local_port_from_disk() -> Option<u16> {
.as_ref()
.and_then(|v| v.get("desktopLocalPort"))
.and_then(|v| v.as_u64())
.and_then(|v| if v > 0 && v <= u16::MAX as u64 { Some(v as u16) } else { None })
.and_then(|v| {
if v > 0 && v <= u16::MAX as u64 {
Some(v as u16)
} else {
None
}
})
}
fn write_desktop_local_port_to_disk(port: u16) -> Result<()> {
@@ -1160,7 +1240,6 @@ fn write_desktop_local_port_to_disk(port: u16) -> Result<()> {
Ok(())
}
fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig {
read_desktop_hosts_config_from_path(&settings_file_path())
}
@@ -1305,7 +1384,6 @@ fn desktop_hosts_set(config: DesktopHostsConfig) -> Result<(), String> {
write_desktop_hosts_config_to_disk(&config).map_err(|err| err.to_string())
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct HostProbeResult {
@@ -1389,7 +1467,8 @@ fn is_nonempty_string(value: &str) -> bool {
!value.trim().is_empty()
}
const CHANGELOG_URL: &str = "https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md";
const CHANGELOG_URL: &str =
"https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md";
fn parse_semver_num(value: &str) -> Option<u32> {
let trimmed = value.trim().trim_start_matches('v');
@@ -1456,7 +1535,10 @@ async fn fetch_changelog_notes(from_version: &str, to_version: &str) -> Option<S
let mut relevant: Vec<String> = Vec::new();
for idx in 0..markers.len() {
let (start, ver_num) = markers[idx];
let end = markers.get(idx + 1).map(|m| m.0).unwrap_or_else(|| changelog.len());
let end = markers
.get(idx + 1)
.map(|m| m.0)
.unwrap_or_else(|| changelog.len());
let Some(ver_num) = ver_num else {
continue;
};
@@ -1627,8 +1709,15 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
}
let mut candidate = value.to_string();
if fs::metadata(&candidate).map(|m| m.is_dir()).unwrap_or(false) {
let bin_name = if cfg!(windows) { "opencode.exe" } else { "opencode" };
if fs::metadata(&candidate)
.map(|m| m.is_dir())
.unwrap_or(false)
{
let bin_name = if cfg!(windows) {
"opencode.exe"
} else {
"opencode"
};
candidate = PathBuf::from(candidate)
.join(bin_name)
.to_string_lossy()
@@ -1685,13 +1774,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result<String> {
push_unique("/usr/sbin".to_string());
push_unique("/sbin".to_string());
if let Some(home) = resolved_home_dir.as_deref() {
// OpenCode installer default.
push_unique(format!("{home}/.opencode/bin"));
push_unique(format!("{home}/.local/bin"));
push_unique(format!("{home}/.bun/bin"));
push_unique(format!("{home}/.cargo/bin"));
push_unique(format!("{home}/bin"));
if let Some(home) = resolved_home_dir.as_deref() {
// OpenCode installer default.
push_unique(format!("{home}/.opencode/bin"));
push_unique(format!("{home}/.local/bin"));
push_unique(format!("{home}/.bun/bin"));
push_unique(format!("{home}/.cargo/bin"));
push_unique(format!("{home}/bin"));
}
if let Ok(existing) = env::var("PATH") {
@@ -1978,7 +2067,13 @@ fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), S
let local_origin = app
.try_state::<DesktopUiInjectionState>()
.and_then(|state| state.local_origin.lock().expect("desktop local origin mutex").clone())
.and_then(|state| {
state
.local_origin
.lock()
.expect("desktop local origin mutex")
.clone()
})
.ok_or_else(|| "Local origin not yet known (sidecar may still be starting)".to_string())?;
create_window(&app, &url, &local_origin, false).map_err(|e| e.to_string())
@@ -1993,7 +2088,8 @@ fn desktop_read_file(path: String) -> Result<FileContent, String> {
let path = Path::new(&path);
// Check file size (max 50MB)
let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {e}"))?;
let metadata =
std::fs::metadata(path).map_err(|e| format!("Failed to read file metadata: {e}"))?;
let size = metadata.len();
if size > 50 * 1024 * 1024 {
return Err("File is too large. Maximum size is 50MB.".to_string());
@@ -2003,7 +2099,11 @@ fn desktop_read_file(path: String) -> Result<FileContent, String> {
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?;
// Detect mime type from extension
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mime = match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
@@ -2055,11 +2155,16 @@ fn macos_major_version() -> Option<u32> {
// Use marketing version (sw_vers), but map legacy 10.x to minor (10.15 -> 15).
// This matches WebKit UA fallback logic in the UI.
if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"]).or_else(|| cmd_stdout("sw_vers", &["-productVersion"])) {
if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"])
.or_else(|| cmd_stdout("sw_vers", &["-productVersion"]))
{
let raw = raw.trim();
let mut parts = raw.split('.');
let major = parts.next().and_then(|v| v.parse::<u32>().ok())?;
let minor = parts.next().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
let minor = parts
.next()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(0);
return Some(if major == 10 { minor } else { major });
}
@@ -2087,7 +2192,8 @@ fn macos_major_version() -> Option<u32> {
/// Build the initialization script injected into every webview window.
/// This is computed once and reused for all windows.
fn build_init_script(local_origin: &str) -> String {
let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default();
let home =
std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default();
let macos_major = macos_major_version().unwrap_or(0);
let home_json = serde_json::to_string(&home).unwrap_or_else(|_| "\"\"".into());
@@ -2161,8 +2267,12 @@ fn capture_window_state(window: &tauri::Window) -> Option<DesktopWindowState> {
Some(DesktopWindowState {
x: (position.x as f64 / scale).round() as i32,
y: (position.y as f64 / scale).round() as i32,
width: (size.width as f64 / scale).round().max(MIN_WINDOW_WIDTH as f64) as u32,
height: (size.height as f64 / scale).round().max(MIN_WINDOW_HEIGHT as f64) as u32,
width: (size.width as f64 / scale)
.round()
.max(MIN_WINDOW_WIDTH as f64) as u32,
height: (size.height as f64 / scale)
.round()
.max(MIN_WINDOW_HEIGHT as f64) as u32,
maximized: window.is_maximized().unwrap_or(false),
fullscreen: window.is_fullscreen().unwrap_or(false),
})
@@ -2179,7 +2289,10 @@ fn schedule_window_state_persist(window: tauri::Window, immediate: bool) {
let Some(state) = app.try_state::<WindowGeometryDebounceState>() else {
return;
};
let mut guard = state.revisions.lock().expect("window geometry debounce mutex");
let mut guard = state
.revisions
.lock()
.expect("window geometry debounce mutex");
let next = guard.get(&label).copied().unwrap_or(0).saturating_add(1);
guard.insert(label.clone(), next);
next
@@ -2215,7 +2328,12 @@ fn schedule_window_state_persist(window: tauri::Window, immediate: bool) {
}
/// Create a new window with a unique label, pointing at the given URL.
fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str, restore_geometry: bool) -> Result<()> {
fn create_window(
app: &tauri::AppHandle,
url: &str,
local_origin: &str,
restore_geometry: bool,
) -> Result<()> {
let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?;
let label = next_window_label();
@@ -2224,7 +2342,10 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str, restore_
// Store the init script and local origin so new windows and page reloads can reuse it.
if let Some(state) = app.try_state::<DesktopUiInjectionState>() {
*state.script.lock().expect("desktop ui injection mutex") = Some(init_script.clone());
*state.local_origin.lock().expect("desktop local origin mutex") = Some(local_origin.to_string());
*state
.local_origin
.lock()
.expect("desktop local origin mutex") = Some(local_origin.to_string());
}
let restored_state = if restore_geometry {
@@ -2240,8 +2361,7 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str, restore_
.decorations(true)
.visible(false)
.initialization_script(&init_script)
.background_throttling(BackgroundThrottlingPolicy::Disabled)
;
.background_throttling(BackgroundThrottlingPolicy::Disabled);
let apply_restored_state = restored_state
.as_ref()
@@ -2261,7 +2381,10 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str, restore_
builder = builder
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { x: 17.0, y: 26.0 }));
.traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition {
x: 17.0,
y: 26.0,
}));
}
let window = builder.build()?;
@@ -2301,7 +2424,13 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str, restore_
fn open_new_window(app: &tauri::AppHandle) {
let local_origin = app
.try_state::<DesktopUiInjectionState>()
.and_then(|state| state.local_origin.lock().expect("desktop local origin mutex").clone());
.and_then(|state| {
state
.local_origin
.lock()
.expect("desktop local origin mutex")
.clone()
});
let Some(local_origin) = local_origin else {
log::warn!("[desktop] cannot open new window: local origin not yet known (sidecar may still be starting)");
@@ -2336,11 +2465,20 @@ fn open_new_window(app: &tauri::AppHandle) {
if target_url != local_ui_url {
let is_cached_unreachable = app
.try_state::<DesktopUiInjectionState>()
.map(|state| state.unreachable_hosts.lock().expect("unreachable hosts mutex").contains(&target_url))
.map(|state| {
state
.unreachable_hosts
.lock()
.expect("unreachable hosts mutex")
.contains(&target_url)
})
.unwrap_or(false);
if is_cached_unreachable {
log::info!("[desktop] new window: default host ({}) cached as unreachable, using local", target_url);
log::info!(
"[desktop] new window: default host ({}) cached as unreachable, using local",
target_url
);
target_url = local_ui_url;
}
}
@@ -2365,6 +2503,7 @@ fn main() {
.manage(WindowFocusState::default())
.manage(WindowGeometryDebounceState::default())
.manage(MenuRuntimeState::default())
.manage(DesktopSshManagerState::default())
.manage(PendingUpdate(Mutex::new(None)))
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
@@ -2547,6 +2686,9 @@ fn main() {
// If this was the last window, kill the sidecar and exit.
let remaining = app.webview_windows().len();
if remaining == 0 {
if let Some(state) = app.try_state::<DesktopSshManagerState>() {
state.shutdown_all(&app);
}
kill_sidecar(app.clone());
app.exit(0);
}
@@ -2576,6 +2718,14 @@ fn main() {
desktop_hosts_get,
desktop_hosts_set,
desktop_host_probe,
remote_ssh::desktop_ssh_instances_get,
remote_ssh::desktop_ssh_instances_set,
remote_ssh::desktop_ssh_import_hosts,
remote_ssh::desktop_ssh_connect,
remote_ssh::desktop_ssh_disconnect,
remote_ssh::desktop_ssh_status,
remote_ssh::desktop_ssh_logs,
remote_ssh::desktop_ssh_logs_clear,
desktop_read_file,
])
.setup(|app| {
@@ -2693,13 +2843,22 @@ fn main() {
match event {
tauri::RunEvent::ExitRequested { .. } => {
// Best-effort cleanup; never block shutdown.
if let Some(state) = app_handle.try_state::<DesktopSshManagerState>() {
state.shutdown_all(app_handle);
}
kill_sidecar(app_handle.clone());
}
tauri::RunEvent::Exit => {
if let Some(state) = app_handle.try_state::<DesktopSshManagerState>() {
state.shutdown_all(app_handle);
}
kill_sidecar(app_handle.clone());
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen { has_visible_windows, .. } => {
tauri::RunEvent::Reopen {
has_visible_windows,
..
} => {
// macOS: clicking dock icon when no windows are open opens a new one.
if !has_visible_windows {
open_new_window(app_handle);
@@ -2734,7 +2893,10 @@ mod tests {
fn sanitize_host_url_for_storage_strips_fragment_and_keeps_query() {
let input = "https://example.com/workspace?coder_session_token=xxxxxx#ignored";
let sanitized = sanitize_host_url_for_storage(input).expect("sanitized url");
assert_eq!(sanitized, "https://example.com/workspace?coder_session_token=xxxxxx");
assert_eq!(
sanitized,
"https://example.com/workspace?coder_session_token=xxxxxx"
);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -23,8 +23,10 @@ import {
RiLoader4Line,
RiMore2Line,
RiPencilLine,
RiPlug2Line,
RiRefreshLine,
RiServerLine,
RiSettings3Line,
RiShieldKeyholeLine,
RiStarFill,
RiStarLine,
@@ -34,6 +36,7 @@ import {
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import {
desktopHostProbe,
desktopHostsGet,
@@ -45,8 +48,17 @@ import {
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
import {
desktopSshConnect,
desktopSshDisconnect,
desktopSshInstancesGet,
desktopSshStatus,
type DesktopSshInstanceStatus,
} from '@/lib/desktopSsh';
const LOCAL_HOST_ID = 'local';
const SSH_CONNECT_TIMEOUT_MS = 90_000;
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
type HostStatus = {
status: HostProbeResult['status'];
@@ -103,6 +115,90 @@ const statusIcon = (status: HostProbeResult['status'] | null) => {
return <RiEarthLine className="h-4 w-4" />;
};
const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
const sshPhaseLabel = (phase: DesktopSshInstanceStatus['phase'] | undefined): string => {
switch (phase) {
case 'ready':
return 'Ready';
case 'error':
return 'Error';
case 'degraded':
return 'Reconnecting';
case 'config_resolved':
return 'Resolving config';
case 'auth_check':
return 'Checking auth';
case 'master_connecting':
return 'Connecting SSH';
case 'remote_probe':
return 'Probing remote';
case 'installing':
return 'Installing';
case 'updating':
return 'Updating';
case 'server_detecting':
return 'Detecting server';
case 'server_starting':
return 'Starting server';
case 'forwarding':
return 'Forwarding ports';
default:
return 'Idle';
}
};
const sshPhaseToHostStatus = (
phase: DesktopSshInstanceStatus['phase'] | undefined,
): HostProbeResult['status'] | null => {
if (!phase || phase === 'idle') return null;
if (phase === 'ready') return 'ok';
if (phase === 'error') return 'unreachable';
return 'auth';
};
const getSshStatusById = async (): Promise<Record<string, DesktopSshInstanceStatus>> => {
const statuses = await desktopSshStatus().catch(() => []);
const next: Record<string, DesktopSshInstanceStatus> = {};
for (const status of statuses) {
next[status.id] = status;
}
return next;
};
const waitForSshReady = async (
id: string,
timeoutMs: number,
onUpdate: (status: DesktopSshInstanceStatus) => void,
shouldCancel?: () => boolean,
): Promise<DesktopSshInstanceStatus> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (shouldCancel?.()) {
throw new Error(SSH_CONNECT_CANCELLED_ERROR);
}
const statuses = await desktopSshStatus(id).catch(() => []);
const status = statuses.find((item) => item.id === id);
if (status) {
onUpdate(status);
if (status.phase === 'ready') {
return status;
}
if (status.phase === 'error') {
throw new Error(status.detail || 'SSH connection failed');
}
}
await sleep(700);
}
if (shouldCancel?.()) {
throw new Error(SSH_CONNECT_CANCELLED_ERROR);
}
throw new Error('Timed out waiting for SSH connection');
};
const buildLocalHost = (): DesktopHost => ({
id: LOCAL_HOST_ID,
label: 'Local',
@@ -147,6 +243,9 @@ export function DesktopHostSwitcherDialog({
embedded = false,
onHostSwitched,
}: DesktopHostSwitcherDialogProps) {
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
@@ -154,6 +253,23 @@ export function DesktopHostSwitcherDialog({
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
const [sshStatusesById, setSshStatusesById] = React.useState<Record<string, DesktopSshInstanceStatus>>({});
const [sshSwitchModal, setSshSwitchModal] = React.useState<{
open: boolean;
hostId: string | null;
hostLabel: string;
phase: DesktopSshInstanceStatus['phase'] | 'idle';
detail: string | null;
error: string | null;
}>({
open: false,
hostId: null,
hostLabel: '',
phase: 'idle',
detail: null,
error: null,
});
const [error, setError] = React.useState<string>('');
const [editingId, setEditingId] = React.useState<string | null>(null);
@@ -163,6 +279,7 @@ export function DesktopHostSwitcherDialog({
const [newLabel, setNewLabel] = React.useState('');
const [newUrl, setNewUrl] = React.useState('');
const [isAddFormOpen, setIsAddFormOpen] = React.useState(!embedded);
const sshSwitchTokenRef = React.useRef(0);
const allHosts = React.useMemo(() => {
const local = buildLocalHost();
@@ -184,7 +301,6 @@ export function DesktopHostSwitcherDialog({
setIsSaving(true);
setError('');
try {
// Persist only remote hosts; Local is derived.
const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID);
await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId });
setConfigHosts(remote);
@@ -196,18 +312,36 @@ export function DesktopHostSwitcherDialog({
}
}, []);
const openRemoteInstancesSettings = React.useCallback(() => {
setSettingsPage('remote-instances');
setSettingsDialogOpen(true);
onOpenChange(false);
}, [onOpenChange, setSettingsDialogOpen, setSettingsPage]);
const refresh = React.useCallback(async () => {
if (!isTauriShell()) return;
setIsLoading(true);
setError('');
try {
const cfg = await desktopHostsGet();
const [cfg, sshCfg, sshStatusMap] = await Promise.all([
desktopHostsGet(),
desktopSshInstancesGet().catch(() => ({ instances: [] })),
getSshStatusById(),
]);
const nextSshHostIds: Record<string, true> = {};
for (const instance of sshCfg.instances) {
nextSshHostIds[instance.id] = true;
}
setConfigHosts(cfg.hosts || []);
setDefaultHostId(cfg.defaultHostId ?? null);
setSshHostIds(nextSshHostIds);
setSshStatusesById(sshStatusMap);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setConfigHosts([]);
setDefaultHostId(null);
setSshHostIds({});
setSshStatusesById({});
} finally {
setIsLoading(false);
}
@@ -245,6 +379,8 @@ export function DesktopHostSwitcherDialog({
setNewLabel('');
setNewUrl('');
setIsAddFormOpen(!embedded);
setSwitchingHostId(null);
setSshSwitchModal({ open: false, hostId: null, hostLabel: '', phase: 'idle', detail: null, error: null });
setError('');
return;
}
@@ -256,10 +392,118 @@ export function DesktopHostSwitcherDialog({
void probeAll(allHosts);
}, [open, allHosts, probeAll]);
React.useEffect(() => {
if (!open || !isTauriShell()) {
return;
}
let cancelled = false;
const run = async () => {
const statuses = await getSshStatusById();
if (!cancelled) {
setSshStatusesById(statuses);
}
};
void run();
const interval = window.setInterval(() => {
void run();
}, 1_500);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [open]);
const handleSwitch = React.useCallback(async (host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
if (!origin) return;
const isSshHost = Boolean(sshHostIds[host.id]);
if (host.id !== LOCAL_HOST_ID && isSshHost && isTauriShell()) {
let existingStatus = sshStatusesById[host.id];
const latestStatus = await desktopSshStatus(host.id)
.then((items) => items.find((item) => item.id === host.id) || null)
.catch(() => null);
if (latestStatus) {
existingStatus = latestStatus;
setSshStatusesById((prev) => ({
...prev,
[host.id]: latestStatus,
}));
}
const existingUrl = normalizeHostUrl(existingStatus?.localUrl || host.url || '');
if (existingStatus?.phase === 'ready' && existingUrl) {
const target = toNavigationUrl(existingUrl);
onHostSwitched?.();
window.location.assign(target);
return;
}
setSwitchingHostId(host.id);
const switchToken = sshSwitchTokenRef.current + 1;
sshSwitchTokenRef.current = switchToken;
setSshSwitchModal({
open: true,
hostId: host.id,
hostLabel: redactSensitiveUrl(host.label),
phase: 'master_connecting',
detail: null,
error: null,
});
try {
await desktopSshConnect(host.id);
if (switchToken !== sshSwitchTokenRef.current) {
return;
}
const readyStatus = await waitForSshReady(host.id, SSH_CONNECT_TIMEOUT_MS, (status) => {
setSshStatusesById((prev) => ({
...prev,
[status.id]: status,
}));
setSshSwitchModal((prev) => ({
...prev,
phase: status.phase,
detail: status.detail || null,
}));
}, () => switchToken !== sshSwitchTokenRef.current);
if (switchToken !== sshSwitchTokenRef.current) {
return;
}
const targetOrigin = normalizeHostUrl(readyStatus.localUrl || '') || origin;
const target = toNavigationUrl(targetOrigin);
onHostSwitched?.();
window.location.assign(target);
return;
} catch (err) {
if (switchToken !== sshSwitchTokenRef.current) {
return;
}
const message = err instanceof Error ? err.message : String(err);
if (message === SSH_CONNECT_CANCELLED_ERROR) {
return;
}
setSshSwitchModal((prev) => ({
...prev,
error: message,
}));
toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
description: message,
});
return;
} finally {
if (switchToken === sshSwitchTokenRef.current) {
setSwitchingHostId(null);
}
}
}
if (host.id !== LOCAL_HOST_ID && isTauriShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
@@ -283,7 +527,7 @@ export function DesktopHostSwitcherDialog({
} catch {
window.location.href = target;
}
}, [onHostSwitched]);
}, [onHostSwitched, sshHostIds, sshStatusesById]);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
@@ -358,6 +602,76 @@ export function DesktopHostSwitcherDialog({
});
}, []);
const switchToLocal = React.useCallback(() => {
sshSwitchTokenRef.current += 1;
setSwitchingHostId(null);
setSshSwitchModal((prev) => ({
...prev,
open: false,
hostId: null,
error: null,
detail: null,
phase: 'idle',
}));
const localTarget = toNavigationUrl(getLocalOrigin());
onHostSwitched?.();
window.location.assign(localTarget);
}, [onHostSwitched]);
const cancelSshSwitch = React.useCallback(async () => {
const hostId = sshSwitchModal.hostId || switchingHostId;
sshSwitchTokenRef.current += 1;
setSwitchingHostId(null);
setSshSwitchModal({
open: false,
hostId: null,
hostLabel: '',
phase: 'idle',
detail: null,
error: null,
});
if (!hostId || hostId === LOCAL_HOST_ID || !isTauriShell()) {
return;
}
await desktopSshDisconnect(hostId).catch(() => {});
}, [sshSwitchModal.hostId, switchingHostId]);
const retrySshSwitch = React.useCallback(() => {
const hostId = sshSwitchModal.hostId;
if (!hostId) return;
const host = allHosts.find((item) => item.id === hostId);
if (!host) return;
void handleSwitch(host);
}, [allHosts, handleSwitch, sshSwitchModal.hostId]);
const connectSshHostInPlace = React.useCallback(async (host: DesktopHost) => {
if (!isTauriShell()) return;
setSwitchingHostId(host.id);
try {
await desktopSshConnect(host.id);
const readyStatus = await waitForSshReady(host.id, SSH_CONNECT_TIMEOUT_MS, (status) => {
setSshStatusesById((prev) => ({
...prev,
[status.id]: status,
}));
});
if (readyStatus.phase === 'ready') {
toast.success(`SSH instance "${redactSensitiveUrl(host.label)}" connected`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message !== SSH_CONNECT_CANCELLED_ERROR) {
toast.error(`SSH instance "${redactSensitiveUrl(host.label)}" failed to connect`, {
description: message,
});
}
} finally {
setSwitchingHostId(null);
}
}, []);
if (!isDesktopShell()) {
return null;
}
@@ -426,6 +740,18 @@ export function DesktopHostSwitcherDialog({
</div>
)}
{tauriAvailable && (
<div className="flex-shrink-0 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">Need SSH instances? Manage them in Settings.</span>
<Button type="button" variant="outline" size="sm" onClick={openRemoteInstancesSettings}>
<RiSettings3Line className="h-4 w-4" />
Remote SSH
</Button>
</div>
</div>
)}
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="typography-meta text-muted-foreground">
@@ -441,9 +767,12 @@ export function DesktopHostSwitcherDialog({
) : (
allHosts.map((host) => {
const isLocal = host.id === LOCAL_HOST_ID;
const isSsh = Boolean(sshHostIds[host.id]);
const isActive = host.id === current.id;
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
const statusKind = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (status?.status ?? null);
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
const displayLabel = redactSensitiveUrl(host.label);
@@ -467,20 +796,25 @@ export function DesktopHostSwitcherDialog({
disabled={switchingHostId === host.id}
aria-label={`Switch to ${displayLabel}`}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(statusKind))} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
{displayLabel}
</span>
{isSsh && (
<span className="typography-micro px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
</span>
)}
{isActive && (
<span className="typography-micro text-muted-foreground">Current</span>
)}
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
{statusIcon(status?.status ?? null)}
{statusIcon(statusKind)}
<span>
{statusLabel(status?.status ?? null)}
{status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
{isSsh ? sshPhaseLabel(sshStatus?.phase) : statusLabel(status?.status ?? null)}
{!isSsh && status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
</span>
</span>
</div>
@@ -491,7 +825,7 @@ export function DesktopHostSwitcherDialog({
</button>
<div className="flex items-center gap-2 flex-shrink-0">
{!isLocal && (
{!isLocal && !isSsh && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -537,6 +871,30 @@ export function DesktopHostSwitcherDialog({
/>
)}
{isSsh && !isLocal && (
(sshStatus?.phase === 'idle' || !sshStatus?.phase) ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5"
disabled={switchingHostId === host.id}
onClick={(e) => {
e.stopPropagation();
void connectSshHostInPlace(host);
}}
>
{switchingHostId === host.id ? <RiLoader4Line className="h-3.5 w-3.5 animate-spin" /> : <RiPlug2Line className="h-3.5 w-3.5" />}
Connect
</Button>
) : (
<div
className="h-8 w-8 opacity-0 pointer-events-none"
aria-hidden="true"
/>
)
)}
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
@@ -563,24 +921,24 @@ export function DesktopHostSwitcherDialog({
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
status?.status === 'unreachable'
? 'text-muted-foreground/30 cursor-not-allowed'
: 'text-muted-foreground/60 hover:text-foreground',
)}
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
statusKind === 'unreachable'
? 'text-muted-foreground/30 cursor-not-allowed'
: 'text-muted-foreground/60 hover:text-foreground',
)}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(host);
}}
disabled={status?.status === 'unreachable'}
disabled={statusKind === 'unreachable'}
aria-label="Open in new window"
>
<RiWindowLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{status?.status === 'unreachable' ? 'Instance unreachable' : 'Open in new window'}
{statusKind === 'unreachable' ? 'Instance unreachable' : 'Open in new window'}
</TooltipContent>
</Tooltip>
</div>
@@ -689,20 +1047,77 @@ export function DesktopHostSwitcherDialog({
</>
);
const sshSwitchDialog = (
<Dialog
open={sshSwitchModal.open}
onOpenChange={(nextOpen) => {
if (!nextOpen && switchingHostId) {
void cancelSshSwitch();
return;
}
setSshSwitchModal((prev) => ({
...prev,
open: nextOpen,
...(nextOpen ? {} : { hostId: null, error: null, detail: null, phase: 'idle' as const }),
}));
}}
>
<DialogContent className="w-[min(28rem,calc(100vw-2rem))] max-w-none">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RiLoader4Line className={cn('h-4 w-4', !sshSwitchModal.error && 'animate-spin')} />
Connecting to {sshSwitchModal.hostLabel || 'SSH instance'}
</DialogTitle>
<DialogDescription>
{sshSwitchModal.error
? sshSwitchModal.error
: sshSwitchModal.detail || sshPhaseLabel(sshSwitchModal.phase)}
</DialogDescription>
</DialogHeader>
{sshSwitchModal.error ? (
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={switchToLocal}
>
Switch to Local
</Button>
<Button
type="button"
size="sm"
onClick={retrySshSwitch}
disabled={!sshSwitchModal.hostId}
>
Retry
</Button>
</div>
) : null}
</DialogContent>
</Dialog>
);
if (embedded) {
return (
<div className="w-full max-h-[70vh] flex flex-col overflow-hidden gap-2">
{content}
</div>
<>
<div className="w-full max-h-[70vh] flex flex-col overflow-hidden gap-2">
{content}
</div>
{sshSwitchDialog}
</>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
{content}
</DialogContent>
</Dialog>
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
{content}
</DialogContent>
</Dialog>
{sshSwitchDialog}
</>
);
}
@@ -714,6 +1129,82 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
const attemptedDefaultSshConnectRef = React.useRef(false);
const [startupSshModal, setStartupSshModal] = React.useState<{
open: boolean;
hostId: string | null;
hostLabel: string;
error: string | null;
connecting: boolean;
}>({
open: false,
hostId: null,
hostLabel: '',
error: null,
connecting: false,
});
const connectDefaultSshInstance = React.useCallback(async (
hostId: string,
hostLabel: string,
options?: { showProgress?: boolean },
): Promise<boolean> => {
const showProgress = Boolean(options?.showProgress);
if (showProgress) {
setStartupSshModal({
open: true,
hostId,
hostLabel,
error: null,
connecting: true,
});
}
try {
await desktopSshConnect(hostId);
const ready = await waitForSshReady(hostId, 45_000, () => {});
const localUrl = normalizeHostUrl(ready.localUrl || '');
if (!localUrl) {
throw new Error('Connected but missing forwarded URL');
}
window.location.assign(toNavigationUrl(localUrl));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStartupSshModal({
open: true,
hostId,
hostLabel,
error: message,
connecting: false,
});
return false;
}
}, []);
const switchStartupToLocal = React.useCallback(async () => {
setStartupSshModal({
open: false,
hostId: null,
hostLabel: '',
error: null,
connecting: false,
});
await desktopHostsGet()
.then((cfg) => desktopHostsSet({ hosts: cfg.hosts, defaultHostId: LOCAL_HOST_ID }))
.catch(() => undefined);
window.location.assign(toNavigationUrl(getLocalOrigin()));
}, []);
const retryStartupSsh = React.useCallback(() => {
const hostId = startupSshModal.hostId;
if (!hostId) return;
void connectDefaultSshInstance(hostId, startupSshModal.hostLabel || 'SSH instance', {
showProgress: true,
});
}, [connectDefaultSshInstance, startupSshModal.hostId, startupSshModal.hostLabel]);
React.useEffect(() => {
if (!isTauriShell()) return;
@@ -725,6 +1216,27 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
const local = buildLocalHost();
const all = [local, ...(cfg.hosts || [])];
const current = resolveCurrentHost(all);
if (
!attemptedDefaultSshConnectRef.current &&
current.id === LOCAL_HOST_ID &&
cfg.defaultHostId &&
cfg.defaultHostId !== LOCAL_HOST_ID
) {
const sshCfg = await desktopSshInstancesGet().catch(() => ({ instances: [] }));
const defaultSsh = sshCfg.instances.find((instance) => instance.id === cfg.defaultHostId);
if (defaultSsh) {
attemptedDefaultSshConnectRef.current = true;
const hostLabel = redactSensitiveUrl(
defaultSsh.nickname?.trim() || defaultSsh.sshParsed?.destination || defaultSsh.id,
);
const connected = await connectDefaultSshInstance(cfg.defaultHostId, hostLabel);
if (connected || cancelled) {
return;
}
}
}
if (cancelled) return;
setLabel(redactSensitiveUrl(current.label || 'Instance'));
const normalized = normalizeHostUrl(current.url);
@@ -751,29 +1263,17 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
cancelled = true;
window.clearInterval(interval);
};
}, []);
}, [connectDefaultSshInstance]);
if (!isDesktopShell()) {
return null;
}
const isCurrentlyLocal = (() => {
try {
return locationMatchesHost(window.location.href, getLocalOrigin());
} catch {
return false;
}
})();
const isCurrentlyLocal = locationMatchesHost(window.location.href, getLocalOrigin());
// Fallback label when Tauri IPC is temporarily unavailable.
const fallbackLabel = (() => {
try {
const host = typeof window !== 'undefined' ? window.location.hostname : '';
return host ? host : 'Instance';
} catch {
return 'Instance';
}
})();
const fallbackLabel = typeof window !== 'undefined' && window.location.hostname
? window.location.hostname
: 'Instance';
const effectiveLabel = isCurrentlyLocal
? 'Local'
@@ -811,6 +1311,54 @@ export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHost
</TooltipContent>
</Tooltip>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
<Dialog
open={startupSshModal.open}
onOpenChange={(nextOpen) => {
if (!nextOpen && startupSshModal.connecting) {
return;
}
if (!nextOpen) {
setStartupSshModal((prev) => ({
...prev,
open: false,
connecting: false,
}));
return;
}
setStartupSshModal((prev) => ({ ...prev, open: true }));
}}
>
<DialogContent className="w-[min(30rem,calc(100vw-2rem))] max-w-none">
<DialogHeader>
<DialogTitle>Default SSH instance unavailable</DialogTitle>
<DialogDescription>
{startupSshModal.connecting
? `Connecting to ${startupSshModal.hostLabel || 'SSH instance'}...`
: startupSshModal.error || 'Failed to connect the default SSH instance.'}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void switchStartupToLocal()}
disabled={startupSshModal.connecting}
>
Switch to Local
</Button>
<Button
type="button"
size="sm"
onClick={retryStartupSsh}
disabled={startupSshModal.connecting || !startupSshModal.hostId}
>
{startupSshModal.connecting ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Retry
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
import React from 'react';
import { RiAddLine, RiDeleteBinLine, RiPlug2Line, RiRefreshLine, RiStopLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import type { DesktopSshInstance } from '@/lib/desktopSsh';
type RemoteInstancesSidebarProps = {
onItemSelect?: () => void;
};
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const randomPort = (): number => {
return Math.floor(20000 + Math.random() * 30000);
};
const isPortInUseError = (error: unknown): boolean => {
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
};
const phaseLabel = (phase?: string): string => {
switch (phase) {
case 'ready':
return 'Ready';
case 'error':
return 'Error';
case 'degraded':
return 'Reconnect';
case 'installing':
return 'Installing';
case 'updating':
return 'Updating';
case 'forwarding':
return 'Forwarding';
case 'server_starting':
return 'Starting';
case 'master_connecting':
return 'Connecting';
default:
return 'Idle';
}
};
export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({ onItemSelect }) => {
const instances = useDesktopSshStore((state) => state.instances);
const statusesById = useDesktopSshStore((state) => state.statusesById);
const isLoading = useDesktopSshStore((state) => state.isLoading);
const load = useDesktopSshStore((state) => state.load);
const loadImports = useDesktopSshStore((state) => state.loadImports);
const createFromCommand = useDesktopSshStore((state) => state.createFromCommand);
const connect = useDesktopSshStore((state) => state.connect);
const disconnect = useDesktopSshStore((state) => state.disconnect);
const retry = useDesktopSshStore((state) => state.retry);
const removeInstance = useDesktopSshStore((state) => state.removeInstance);
const upsertInstance = useDesktopSshStore((state) => state.upsertInstance);
const selectedId = useUIStore((state) => state.settingsRemoteInstancesSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsRemoteInstancesSelectedId);
React.useEffect(() => {
void load();
void loadImports();
}, [load, loadImports]);
React.useEffect(() => {
if (isLoading) return;
if (instances.length === 0) {
if (selectedId !== null) {
setSelectedId(null);
}
return;
}
if (selectedId && instances.some((instance) => instance.id === selectedId)) {
return;
}
setSelectedId(instances[0].id);
}, [instances, isLoading, selectedId, setSelectedId]);
const handleAdd = React.useCallback(async () => {
const id = makeId();
try {
await createFromCommand(id, 'ssh user@example.com', 'New SSH Instance');
setSelectedId(id);
onItemSelect?.();
} catch (error) {
toast.error('Failed to create SSH instance', {
description: error instanceof Error ? error.message : String(error),
});
}
}, [createFromCommand, onItemSelect, setSelectedId]);
const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => {
try {
await connect(instance.id);
return;
} catch (error) {
if (!isPortInUseError(error)) {
throw error;
}
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
if (!allow) {
throw error;
}
const nextInstance: DesktopSshInstance = {
...instance,
localForward: {
...instance.localForward,
preferredLocalPort: randomPort(),
},
};
await upsertInstance(nextInstance);
await connect(nextInstance.id);
toast.success('Retried with a random local port');
}
}, [connect, upsertInstance]);
return (
<SettingsSidebarLayout
variant="background"
header={
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Remote Instances</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {instances.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={() => void handleAdd()}
aria-label="Add SSH instance"
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
}
>
{instances.map((instance) => {
const status = statusesById[instance.id];
const selected = instance.id === selectedId;
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const metadata = `${phaseLabel(status?.phase)}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
const isReady = status?.phase === 'ready';
const canRetry = status?.phase === 'error' || status?.phase === 'degraded';
return (
<SettingsSidebarItem
key={instance.id}
title={title}
metadata={metadata}
selected={selected}
onSelect={() => {
setSelectedId(instance.id);
onItemSelect?.();
}}
actions={[
{
label: isReady ? 'Disconnect' : 'Connect',
icon: isReady ? RiStopLine : RiPlug2Line,
onClick: () => {
const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance);
void op.catch((error) => {
toast.error(`Failed to ${isReady ? 'disconnect' : 'connect'} instance`, {
description: error instanceof Error ? error.message : String(error),
});
});
},
},
{
label: 'Retry',
icon: RiRefreshLine,
onClick: () => {
if (!canRetry) return;
void retry(instance.id).catch((error) => {
toast.error('Failed to retry connection', {
description: error instanceof Error ? error.message : String(error),
});
});
},
},
{
label: 'Remove',
icon: RiDeleteBinLine,
destructive: true,
onClick: () => {
void removeInstance(instance.id).then(() => {
if (selectedId === instance.id) {
const next = instances.find((item) => item.id !== instance.id);
setSelectedId(next?.id || null);
}
}).catch((error) => {
toast.error('Failed to remove instance', {
description: error instanceof Error ? error.message : String(error),
});
});
},
},
]}
/>
);
})}
</SettingsSidebarLayout>
);
};
@@ -18,7 +18,7 @@ import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
export const CommandPalette: React.FC = () => {
@@ -122,8 +122,8 @@ export const CommandPalette: React.FC = () => {
};
const settingsRuntimeCtx = React.useMemo<SettingsRuntimeContext>(() => {
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
return { isVSCode: isVSCodeRuntime(), isWeb: isWebRuntime(), isDesktop };
const isDesktop = isDesktopShell();
return { isVSCode: isVSCodeRuntime(), isWeb: !isDesktop && isWebRuntime(), isDesktop };
}, []);
const settingsPages = React.useMemo(() => {
@@ -27,6 +27,7 @@ import {
RiListUnordered,
RiRobot2Line,
RiRestartLine,
RiServerLine,
RiSlashCommands2,
} from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -41,6 +42,8 @@ import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar';
import { ProjectsPage } from '@/components/sections/projects/ProjectsPage';
import { RemoteInstancesSidebar } from '@/components/sections/remote-instances/RemoteInstancesSidebar';
import { RemoteInstancesPage } from '@/components/sections/remote-instances/RemoteInstancesPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
@@ -51,7 +54,7 @@ import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPa
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
import { McpIcon } from '@/components/icons/McpIcon';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import {
SETTINGS_PAGE_METADATA,
@@ -85,6 +88,7 @@ const pageOrder: SettingsPageSlug[] = [
'shortcuts',
'git',
'projects',
'remote-instances',
'agents',
'commands',
'mcp',
@@ -97,7 +101,7 @@ const pageOrder: SettingsPageSlug[] = [
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
const isVSCode = isVSCodeRuntime();
const isWeb = isWebRuntime();
const isWeb = !isDesktop && isWebRuntime();
return { isVSCode, isWeb, isDesktop };
}
@@ -112,6 +116,8 @@ function getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<{ class
switch (slug) {
case 'projects':
return RiFoldersLine;
case 'remote-instances':
return RiServerLine;
case 'appearance':
return RiPaletteLine;
case 'chat':
@@ -245,8 +251,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const containerRef = React.useRef<HTMLDivElement>(null);
const isDesktopApp = React.useMemo(() => {
if (typeof window === 'undefined') return false;
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
return isDesktopShell();
}, []);
// keep platform check available for future window chrome tweaks
@@ -379,6 +384,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
switch (slug) {
case 'projects':
return <ProjectsSidebar onItemSelect={opts.onItemSelect} />;
case 'remote-instances':
return <RemoteInstancesSidebar onItemSelect={opts.onItemSelect} />;
case 'agents':
return <AgentsSidebar onItemSelect={opts.onItemSelect} />;
case 'commands':
@@ -407,6 +414,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <SettingsHome onOpen={openPage} />;
case 'projects':
return <ProjectsPage />;
case 'remote-instances':
return <RemoteInstancesPage />;
case 'agents':
return <AgentsPage />;
case 'commands':
+454
View File
@@ -0,0 +1,454 @@
import { isTauriShell } from '@/lib/desktop';
type TauriInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
type TauriGlobal = {
core?: {
invoke?: TauriInvoke;
};
event?: {
listen?: (
event: string,
handler: (evt: { payload?: unknown }) => void,
) => Promise<() => void>;
};
};
export type DesktopSshRemoteMode = 'managed' | 'external';
export type DesktopSshInstallMethod = 'npm' | 'bun' | 'download_release' | 'upload_bundle';
export type DesktopSshSecretStore = 'never' | 'settings';
export type DesktopSshStoredSecret = {
enabled: boolean;
value?: string;
store: DesktopSshSecretStore;
};
export type DesktopSshPortForwardType = 'local' | 'remote' | 'dynamic';
export type DesktopSshPortForward = {
id: string;
enabled: boolean;
type: DesktopSshPortForwardType;
localHost?: string;
localPort?: number;
remoteHost?: string;
remotePort?: number;
};
export type DesktopSshInstance = {
id: string;
nickname?: string;
sshCommand: string;
sshParsed?: {
destination: string;
args: string[];
};
connectionTimeoutSec: number;
remoteOpenchamber: {
mode: DesktopSshRemoteMode;
keepRunning: boolean;
preferredPort?: number;
installMethod: DesktopSshInstallMethod;
uploadBundleOverSsh: boolean;
};
localForward: {
preferredLocalPort?: number;
bindHost: '127.0.0.1' | 'localhost' | '0.0.0.0';
};
auth: {
sshPassword?: DesktopSshStoredSecret;
openchamberPassword?: DesktopSshStoredSecret;
};
portForwards: DesktopSshPortForward[];
};
export type DesktopSshInstancesConfig = {
instances: DesktopSshInstance[];
};
export type DesktopSshPhase =
| 'idle'
| 'config_resolved'
| 'auth_check'
| 'master_connecting'
| 'remote_probe'
| 'installing'
| 'updating'
| 'server_detecting'
| 'server_starting'
| 'forwarding'
| 'ready'
| 'degraded'
| 'error';
export type DesktopSshInstanceStatus = {
id: string;
phase: DesktopSshPhase;
detail?: string;
localUrl?: string;
localPort?: number;
remotePort?: number;
startedByUs: boolean;
retryAttempt: number;
requiresUserAction: boolean;
updatedAtMs: number;
};
export type DesktopSshImportCandidate = {
host: string;
pattern: boolean;
source: string;
sshCommand: string;
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const readString = (obj: Record<string, unknown>, key: string): string | null => {
const value = obj[key];
return typeof value === 'string' ? value : null;
};
const readNumber = (obj: Record<string, unknown>, key: string): number | null => {
const value = obj[key];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const readBoolean = (obj: Record<string, unknown>, key: string): boolean | null => {
const value = obj[key];
return typeof value === 'boolean' ? value : null;
};
const asStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string');
};
const getInvoke = (): TauriInvoke | null => {
if (!isTauriShell()) return null;
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
};
const parseStoredSecret = (value: unknown): DesktopSshStoredSecret | undefined => {
if (!isRecord(value)) return undefined;
const enabled = readBoolean(value, 'enabled') ?? false;
const rawStore = readString(value, 'store')?.toLowerCase();
const store: DesktopSshSecretStore = rawStore === 'settings' ? 'settings' : 'never';
const rawValue = readString(value, 'value');
return {
enabled,
store,
...(rawValue ? { value: rawValue } : {}),
};
};
const parseForwardType = (value: unknown): DesktopSshPortForwardType => {
return value === 'remote' || value === 'dynamic' ? value : 'local';
};
const parseForward = (value: unknown): DesktopSshPortForward | null => {
if (!isRecord(value)) return null;
const id = readString(value, 'id');
if (!id) return null;
const enabled = readBoolean(value, 'enabled') ?? true;
const type = parseForwardType(readString(value, 'type'));
const localHost = readString(value, 'localHost') || readString(value, 'local_host') || undefined;
const localPort = readNumber(value, 'localPort') ?? readNumber(value, 'local_port') ?? undefined;
const remoteHost = readString(value, 'remoteHost') || readString(value, 'remote_host') || undefined;
const remotePort = readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port') ?? undefined;
return {
id,
enabled,
type,
...(localHost ? { localHost } : {}),
...(typeof localPort === 'number' ? { localPort } : {}),
...(remoteHost ? { remoteHost } : {}),
...(typeof remotePort === 'number' ? { remotePort } : {}),
};
};
const parseInstance = (value: unknown): DesktopSshInstance | null => {
if (!isRecord(value)) return null;
const id = readString(value, 'id');
const sshCommand = readString(value, 'sshCommand') || readString(value, 'ssh_command');
if (!id || !sshCommand) return null;
const nickname = readString(value, 'nickname');
const parsedRaw = value.sshParsed;
const parsed = isRecord(parsedRaw)
? {
destination: readString(parsedRaw, 'destination') || '',
args: asStringArray(parsedRaw.args),
}
: undefined;
const remoteRaw = isRecord(value.remoteOpenchamber)
? value.remoteOpenchamber
: isRecord(value.remote_openchamber)
? value.remote_openchamber
: {};
const localRaw = isRecord(value.localForward)
? value.localForward
: isRecord(value.local_forward)
? value.local_forward
: {};
const authRaw = isRecord(value.auth) ? value.auth : {};
const rawMode = readString(remoteRaw, 'mode')?.toLowerCase();
const mode: DesktopSshRemoteMode = rawMode === 'external' ? 'external' : 'managed';
const rawInstallMethod = readString(remoteRaw, 'installMethod') || readString(remoteRaw, 'install_method');
const installMethod: DesktopSshInstallMethod =
rawInstallMethod === 'npm' ||
rawInstallMethod === 'download_release' ||
rawInstallMethod === 'upload_bundle'
? rawInstallMethod
: 'bun';
const bindHostRaw =
readString(localRaw, 'bindHost') ||
readString(localRaw, 'bind_host') ||
'127.0.0.1';
const bindHost: '127.0.0.1' | 'localhost' | '0.0.0.0' =
bindHostRaw === 'localhost' || bindHostRaw === '0.0.0.0' ? bindHostRaw : '127.0.0.1';
const forwardsRaw = Array.isArray(value.portForwards)
? value.portForwards
: Array.isArray(value.port_forwards)
? value.port_forwards
: [];
const portForwards = forwardsRaw
.map((item) => parseForward(item))
.filter((item): item is DesktopSshPortForward => Boolean(item));
const preferredPort = readNumber(remoteRaw, 'preferredPort') ?? readNumber(remoteRaw, 'preferred_port');
const preferredLocalPort =
readNumber(localRaw, 'preferredLocalPort') ?? readNumber(localRaw, 'preferred_local_port');
const sshPassword = parseStoredSecret(authRaw.sshPassword || authRaw.ssh_password);
const openchamberPassword = parseStoredSecret(authRaw.openchamberPassword || authRaw.openchamber_password);
return {
id,
...(nickname ? { nickname } : {}),
sshCommand,
...(parsed && parsed.destination ? { sshParsed: parsed } : {}),
connectionTimeoutSec:
readNumber(value, 'connectionTimeoutSec') ??
readNumber(value, 'connection_timeout_sec') ??
60,
remoteOpenchamber: {
mode,
keepRunning: readBoolean(remoteRaw, 'keepRunning') ?? readBoolean(remoteRaw, 'keep_running') ?? true,
...(preferredPort ? { preferredPort } : {}),
installMethod,
uploadBundleOverSsh:
readBoolean(remoteRaw, 'uploadBundleOverSsh') ??
readBoolean(remoteRaw, 'upload_bundle_over_ssh') ??
false,
},
localForward: {
...(preferredLocalPort ? { preferredLocalPort } : {}),
bindHost,
},
auth: {
...(sshPassword ? { sshPassword } : {}),
...(openchamberPassword ? { openchamberPassword } : {}),
},
portForwards,
};
};
const parsePhase = (value: unknown): DesktopSshPhase => {
switch (value) {
case 'config_resolved':
case 'auth_check':
case 'master_connecting':
case 'remote_probe':
case 'installing':
case 'updating':
case 'server_detecting':
case 'server_starting':
case 'forwarding':
case 'ready':
case 'degraded':
case 'error':
return value;
default:
return 'idle';
}
};
const parseStatus = (value: unknown): DesktopSshInstanceStatus | null => {
if (!isRecord(value)) return null;
const id = readString(value, 'id');
if (!id) return null;
return {
id,
phase: parsePhase(readString(value, 'phase')),
...(readString(value, 'detail') ? { detail: readString(value, 'detail') || undefined } : {}),
...(readString(value, 'localUrl') || readString(value, 'local_url')
? { localUrl: readString(value, 'localUrl') || readString(value, 'local_url') || undefined }
: {}),
...(typeof (readNumber(value, 'localPort') ?? readNumber(value, 'local_port')) === 'number'
? { localPort: readNumber(value, 'localPort') ?? readNumber(value, 'local_port') ?? undefined }
: {}),
...(typeof (readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port')) === 'number'
? {
remotePort: readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port') ?? undefined,
}
: {}),
startedByUs: readBoolean(value, 'startedByUs') ?? readBoolean(value, 'started_by_us') ?? false,
retryAttempt: readNumber(value, 'retryAttempt') ?? readNumber(value, 'retry_attempt') ?? 0,
requiresUserAction:
readBoolean(value, 'requiresUserAction') ?? readBoolean(value, 'requires_user_action') ?? false,
updatedAtMs: readNumber(value, 'updatedAtMs') ?? readNumber(value, 'updated_at_ms') ?? Date.now(),
};
};
const parseImportCandidate = (value: unknown): DesktopSshImportCandidate | null => {
if (!isRecord(value)) return null;
const host = readString(value, 'host');
const source = readString(value, 'source');
const sshCommand = readString(value, 'sshCommand') || readString(value, 'ssh_command');
if (!host || !source || !sshCommand) return null;
return {
host,
source,
sshCommand,
pattern: readBoolean(value, 'pattern') ?? false,
};
};
export const createDesktopSshInstance = (id: string, sshCommand: string): DesktopSshInstance => {
return {
id,
sshCommand,
connectionTimeoutSec: 60,
remoteOpenchamber: {
mode: 'managed',
keepRunning: true,
installMethod: 'bun',
uploadBundleOverSsh: false,
},
localForward: {
bindHost: '127.0.0.1',
},
auth: {},
portForwards: [],
};
};
export const desktopSshInstancesGet = async (): Promise<DesktopSshInstancesConfig> => {
const invoke = getInvoke();
if (!invoke) {
return { instances: [] };
}
const raw = await invoke('desktop_ssh_instances_get');
if (!isRecord(raw)) {
return { instances: [] };
}
const listRaw = Array.isArray(raw.instances)
? raw.instances
: Array.isArray(raw.desktopSshInstances)
? raw.desktopSshInstances
: [];
const instances = listRaw
.map((item) => parseInstance(item))
.filter((item): item is DesktopSshInstance => Boolean(item));
return { instances };
};
export const desktopSshInstancesSet = async (config: DesktopSshInstancesConfig): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_ssh_instances_set', {
config: {
instances: config.instances,
},
});
};
export const desktopSshImportHosts = async (): Promise<DesktopSshImportCandidate[]> => {
const invoke = getInvoke();
if (!invoke) return [];
const raw = await invoke('desktop_ssh_import_hosts');
if (!Array.isArray(raw)) return [];
return raw
.map((item) => parseImportCandidate(item))
.filter((item): item is DesktopSshImportCandidate => Boolean(item));
};
export const desktopSshConnect = async (id: string): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_ssh_connect', { id });
};
export const desktopSshDisconnect = async (id: string): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_ssh_disconnect', { id });
};
export const desktopSshStatus = async (id?: string): Promise<DesktopSshInstanceStatus[]> => {
const invoke = getInvoke();
if (!invoke) return [];
const raw = await invoke('desktop_ssh_status', {
...(id ? { id } : {}),
});
if (!Array.isArray(raw)) return [];
return raw
.map((item) => parseStatus(item))
.filter((item): item is DesktopSshInstanceStatus => Boolean(item));
};
export const desktopSshLogs = async (id: string, limit?: number): Promise<string[]> => {
const invoke = getInvoke();
if (!invoke) return [];
const raw = await invoke('desktop_ssh_logs', {
id,
...(typeof limit === 'number' ? { limit } : {}),
});
if (!Array.isArray(raw)) return [];
return raw.filter((line): line is string => typeof line === 'string');
};
export const desktopSshLogsClear = async (id: string): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_ssh_logs_clear', { id });
};
export const listenDesktopSshStatus = async (
listener: (status: DesktopSshInstanceStatus) => void,
): Promise<() => Promise<void>> => {
if (!isTauriShell()) {
return async () => {};
}
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
const listen = tauri?.event?.listen;
if (typeof listen !== 'function') {
return async () => {};
}
const unlisten = await listen('openchamber:ssh-instance-status', (event) => {
const status = parseStatus(event?.payload);
if (!status) return;
listener(status);
});
return async () => {
await unlisten();
};
};
+9
View File
@@ -3,6 +3,7 @@ import type { SidebarSection } from '@/constants/sidebar';
export type SettingsPageSlug =
| 'home'
| 'projects'
| 'remote-instances'
| 'providers'
| 'usage'
| 'agents'
@@ -71,6 +72,14 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
kind: 'split',
keywords: ['project', 'projects', 'worktree', 'worktrees', 'repo', 'repository', 'directory'],
},
{
slug: 'remote-instances',
title: 'Remote Instances',
group: 'projects',
kind: 'split',
keywords: ['ssh', 'remote', 'instances', 'tunnels', 'forwarding', 'connection'],
isAvailable: (ctx) => ctx.isDesktop && !ctx.isWeb && !ctx.isVSCode,
},
{
slug: 'providers',
title: 'Providers',
@@ -0,0 +1,191 @@
import { create } from 'zustand';
import {
createDesktopSshInstance,
desktopSshConnect,
desktopSshDisconnect,
desktopSshImportHosts,
desktopSshInstancesGet,
desktopSshInstancesSet,
desktopSshStatus,
listenDesktopSshStatus,
type DesktopSshImportCandidate,
type DesktopSshInstance,
type DesktopSshInstanceStatus,
} from '@/lib/desktopSsh';
type DesktopSshState = {
instances: DesktopSshInstance[];
statusesById: Record<string, DesktopSshInstanceStatus>;
importCandidates: DesktopSshImportCandidate[];
isLoading: boolean;
isSaving: boolean;
isImportsLoading: boolean;
initialized: boolean;
listenerReady: boolean;
error: string | null;
load: () => Promise<void>;
loadImports: () => Promise<void>;
refreshStatuses: () => Promise<void>;
upsertInstance: (instance: DesktopSshInstance) => Promise<void>;
createFromCommand: (id: string, sshCommand: string, nickname?: string) => Promise<void>;
removeInstance: (id: string) => Promise<void>;
setInstances: (instances: DesktopSshInstance[]) => Promise<void>;
connect: (id: string) => Promise<void>;
disconnect: (id: string) => Promise<void>;
retry: (id: string) => Promise<void>;
getStatus: (id: string) => DesktopSshInstanceStatus | null;
clearError: () => void;
};
const byUpdatedAt = (a: DesktopSshInstanceStatus, b: DesktopSshInstanceStatus) => {
return b.updatedAtMs - a.updatedAtMs;
};
export const useDesktopSshStore = create<DesktopSshState>((set, get) => ({
instances: [],
statusesById: {},
importCandidates: [],
isLoading: false,
isSaving: false,
isImportsLoading: false,
initialized: false,
listenerReady: false,
error: null,
load: async () => {
if (get().isLoading) return;
set({ isLoading: true, error: null });
try {
const [config, statuses] = await Promise.all([desktopSshInstancesGet(), desktopSshStatus()]);
const statusMap: Record<string, DesktopSshInstanceStatus> = {};
for (const status of statuses.sort(byUpdatedAt)) {
statusMap[status.id] = status;
}
if (!get().listenerReady) {
await listenDesktopSshStatus((status) => {
set((state) => ({
statusesById: {
...state.statusesById,
[status.id]: status,
},
}));
});
}
set({
instances: config.instances,
statusesById: statusMap,
isLoading: false,
initialized: true,
listenerReady: true,
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : String(error),
});
}
},
loadImports: async () => {
if (get().isImportsLoading) return;
set({ isImportsLoading: true, error: null });
try {
const importCandidates = await desktopSshImportHosts();
set({ importCandidates, isImportsLoading: false });
} catch (error) {
set({
isImportsLoading: false,
error: error instanceof Error ? error.message : String(error),
});
}
},
refreshStatuses: async () => {
try {
const statuses = await desktopSshStatus();
const statusMap: Record<string, DesktopSshInstanceStatus> = {};
for (const status of statuses.sort(byUpdatedAt)) {
statusMap[status.id] = status;
}
set({ statusesById: statusMap });
} catch (error) {
set({ error: error instanceof Error ? error.message : String(error) });
}
},
setInstances: async (instances) => {
set({ isSaving: true, error: null });
try {
await desktopSshInstancesSet({ instances });
set({ instances, isSaving: false });
await get().refreshStatuses();
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
upsertInstance: async (instance) => {
const current = get().instances;
const next = current.some((item) => item.id === instance.id)
? current.map((item) => (item.id === instance.id ? instance : item))
: [instance, ...current];
await get().setInstances(next);
},
createFromCommand: async (id, sshCommand, nickname) => {
const instance = createDesktopSshInstance(id, sshCommand);
if (nickname && nickname.trim()) {
instance.nickname = nickname.trim();
}
await get().upsertInstance(instance);
},
removeInstance: async (id) => {
await desktopSshDisconnect(id).catch(() => undefined);
const next = get().instances.filter((item) => item.id !== id);
await get().setInstances(next);
set((state) => {
const statusesById = { ...state.statusesById };
delete statusesById[id];
return { statusesById };
});
},
connect: async (id) => {
set({ error: null });
try {
await desktopSshConnect(id);
await get().refreshStatuses();
} catch (error) {
set({ error: error instanceof Error ? error.message : String(error) });
throw error;
}
},
disconnect: async (id) => {
set({ error: null });
try {
await desktopSshDisconnect(id);
await get().refreshStatuses();
} catch (error) {
set({ error: error instanceof Error ? error.message : String(error) });
throw error;
}
},
retry: async (id) => {
await get().connect(id);
},
getStatus: (id) => {
return get().statusesById[id] || null;
},
clearError: () => set({ error: null }),
}));
+8
View File
@@ -167,6 +167,7 @@ interface UIStore {
settingsPage: string;
settingsHasOpenedOnce: boolean;
settingsProjectsSelectedId: string | null;
settingsRemoteInstancesSelectedId: string | null;
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
@@ -271,6 +272,7 @@ interface UIStore {
setSidebarSection: (section: SidebarSection) => void;
setSettingsPage: (slug: string) => void;
setSettingsProjectsSelectedId: (projectId: string | null) => void;
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setShowTextJustificationActivity: (value: boolean) => void;
@@ -371,6 +373,7 @@ export const useUIStore = create<UIStore>()(
settingsPage: 'home',
settingsHasOpenedOnce: false,
settingsProjectsSelectedId: null,
settingsRemoteInstancesSelectedId: null,
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
@@ -847,6 +850,10 @@ export const useUIStore = create<UIStore>()(
set({ settingsProjectsSelectedId: projectId });
},
setSettingsRemoteInstancesSelectedId: (instanceId) => {
set({ settingsRemoteInstancesSelectedId: instanceId });
},
setEventStreamStatus: (status, hint) => {
set({
eventStreamStatus: status,
@@ -1343,6 +1350,7 @@ export const useUIStore = create<UIStore>()(
settingsPage: state.settingsPage,
settingsHasOpenedOnce: state.settingsHasOpenedOnce,
settingsProjectsSelectedId: state.settingsProjectsSelectedId,
settingsRemoteInstancesSelectedId: state.settingsRemoteInstancesSelectedId,
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
+22
View File
@@ -36,6 +36,18 @@ const MODELS_METADATA_CACHE_TTL = 5 * 60 * 1000;
const CLIENT_RELOAD_DELAY_MS = 800;
const OPEN_CODE_READY_GRACE_MS = 12000;
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
const OPENCHAMBER_VERSION = (() => {
try {
const packagePath = path.resolve(__dirname, '..', 'package.json');
const raw = fs.readFileSync(packagePath, 'utf8');
const pkg = JSON.parse(raw);
if (pkg && typeof pkg.version === 'string' && pkg.version.trim().length > 0) {
return pkg.version.trim();
}
} catch {
}
return 'unknown';
})();
const fsPromises = fs.promises;
const DEFAULT_FILE_SEARCH_LIMIT = 60;
const MAX_FILE_SEARCH_LIMIT = 400;
@@ -5791,6 +5803,7 @@ async function main(options = {}) {
}
const app = express();
const serverStartedAt = new Date().toISOString();
app.set('trust proxy', true);
expressApp = app;
server = http.createServer(app);
@@ -5822,6 +5835,15 @@ async function main(options = {}) {
});
});
app.get('/api/system/info', (req, res) => {
res.json({
openchamberVersion: OPENCHAMBER_VERSION,
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
pid: process.pid,
startedAt: serverStartedAt,
});
});
app.use((req, res, next) => {
if (
req.path.startsWith('/api/config/agents') ||