diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 73376c12..5c095cd1 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -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( .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( 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( )?; 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( 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( 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) -> 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 = 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 = refreshed.iter().map(|entry| entry.name.clone()).collect(); + let names: Vec = + 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 = 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 = refreshed.iter().map(|entry| entry.name.clone()).collect(); + let names: Vec = + 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 { } } - 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 { #[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 { } 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 { } 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 { .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 { let trimmed = value.trim().trim_start_matches('v'); @@ -1456,7 +1535,10 @@ async fn fetch_changelog_notes(from_version: &str, to_version: &str) -> Option = 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 { } 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 { 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::() - .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 { 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 { 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 { // 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::().ok())?; - let minor = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(0); + let minor = parts + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); return Some(if major == 10 { minor } else { major }); } @@ -2087,7 +2192,8 @@ fn macos_major_version() -> Option { /// 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 { 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::() 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::() { *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::() - .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::() - .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::() { + 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::() { + state.shutdown_all(app_handle); + } kill_sidecar(app_handle.clone()); } tauri::RunEvent::Exit => { + if let Some(state) = app_handle.try_state::() { + 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] diff --git a/packages/desktop/src-tauri/src/remote_ssh.rs b/packages/desktop/src-tauri/src/remote_ssh.rs new file mode 100644 index 00000000..141519aa --- /dev/null +++ b/packages/desktop/src-tauri/src/remote_ssh.rs @@ -0,0 +1,2969 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Read, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tauri::{AppHandle, Emitter, State}; + +const LOCAL_HOST_ID: &str = "local"; +const SSH_STATUS_EVENT: &str = "openchamber:ssh-instance-status"; +const DEFAULT_CONNECTION_TIMEOUT_SEC: u16 = 60; +const DEFAULT_LOCAL_BIND_HOST: &str = "127.0.0.1"; +const DEFAULT_CONTROL_PERSIST_SEC: u16 = 300; +const DEFAULT_READY_TIMEOUT_SEC: u64 = 30; +const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5; +const MAX_LOG_LINES_PER_INSTANCE: usize = 1200; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshInstancesConfig { + pub instances: Vec, +} + +impl Default for DesktopSshInstancesConfig { + fn default() -> Self { + Self { + instances: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshParsedCommand { + pub destination: String, + pub args: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DesktopSshRemoteMode { + Managed, + External, +} + +impl Default for DesktopSshRemoteMode { + fn default() -> Self { + Self::Managed + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DesktopSshInstallMethod { + Npm, + Bun, + DownloadRelease, + UploadBundle, +} + +impl Default for DesktopSshInstallMethod { + fn default() -> Self { + Self::Bun + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshRemoteOpenchamberConfig { + #[serde(default)] + pub mode: DesktopSshRemoteMode, + #[serde(default = "default_true")] + pub keep_running: bool, + pub preferred_port: Option, + #[serde(default)] + pub install_method: DesktopSshInstallMethod, + #[serde(default)] + pub upload_bundle_over_ssh: bool, +} + +impl Default for DesktopSshRemoteOpenchamberConfig { + fn default() -> Self { + Self { + mode: DesktopSshRemoteMode::Managed, + keep_running: true, + preferred_port: None, + install_method: DesktopSshInstallMethod::Bun, + upload_bundle_over_ssh: false, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshLocalForwardConfig { + pub preferred_local_port: Option, + #[serde(default = "default_local_bind_host")] + pub bind_host: String, +} + +impl Default for DesktopSshLocalForwardConfig { + fn default() -> Self { + Self { + preferred_local_port: None, + bind_host: default_local_bind_host(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DesktopSshSecretStore { + Never, + Settings, +} + +impl Default for DesktopSshSecretStore { + fn default() -> Self { + Self::Never + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshStoredSecret { + #[serde(default)] + pub enabled: bool, + pub value: Option, + #[serde(default)] + pub store: DesktopSshSecretStore, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshAuthConfig { + pub ssh_password: Option, + pub openchamber_password: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DesktopSshPortForwardType { + Local, + Remote, + Dynamic, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshPortForward { + pub id: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(rename = "type")] + pub forward_type: DesktopSshPortForwardType, + pub local_host: Option, + pub local_port: Option, + pub remote_host: Option, + pub remote_port: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshInstance { + pub id: String, + pub nickname: Option, + pub ssh_command: String, + pub ssh_parsed: Option, + #[serde(default = "default_connection_timeout")] + pub connection_timeout_sec: u16, + #[serde(default)] + pub remote_openchamber: DesktopSshRemoteOpenchamberConfig, + #[serde(default)] + pub local_forward: DesktopSshLocalForwardConfig, + #[serde(default)] + pub auth: DesktopSshAuthConfig, + #[serde(default)] + pub port_forwards: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DesktopSshPhase { + Idle, + ConfigResolved, + AuthCheck, + MasterConnecting, + RemoteProbe, + Installing, + Updating, + ServerDetecting, + ServerStarting, + Forwarding, + Ready, + Degraded, + Error, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshInstanceStatus { + pub id: String, + pub phase: DesktopSshPhase, + pub detail: Option, + pub local_url: Option, + pub local_port: Option, + pub remote_port: Option, + #[serde(default)] + pub started_by_us: bool, + #[serde(default)] + pub retry_attempt: u32, + #[serde(default)] + pub requires_user_action: bool, + pub updated_at_ms: u64, +} + +impl DesktopSshInstanceStatus { + fn idle(id: impl Into) -> Self { + Self { + id: id.into(), + phase: DesktopSshPhase::Idle, + detail: None, + local_url: None, + local_port: None, + remote_port: None, + started_by_us: false, + retry_attempt: 0, + requires_user_action: false, + updated_at_ms: now_millis(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopSshImportCandidate { + pub host: String, + pub pattern: bool, + pub source: String, + pub ssh_command: String, +} + +#[derive(Default)] +struct DesktopSshManagerInner { + statuses: Mutex>, + logs: Mutex>>, + sessions: Mutex>, + connect_tasks: Mutex>>, + monitor_tasks: Mutex>>, + reconnect_attempts: Mutex>, + connect_attempts: Mutex>, +} + +struct SshSession { + instance: DesktopSshInstance, + parsed: DesktopSshParsedCommand, + session_dir: PathBuf, + control_path: PathBuf, + local_port: u16, + remote_port: u16, + started_by_us: bool, + master: Child, + master_detached: bool, + main_forward: Child, + main_forward_detached: bool, + extra_forwards: Vec, +} + +#[derive(Default)] +pub struct DesktopSshManagerState { + inner: Arc, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteSystemInfo { + openchamber_version: Option, + runtime: Option, + pid: Option, + started_at: Option, +} + +fn default_true() -> bool { + true +} + +fn default_connection_timeout() -> u16 { + DEFAULT_CONNECTION_TIMEOUT_SEC +} + +fn default_local_bind_host() -> String { + DEFAULT_LOCAL_BIND_HOST.to_string() +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn settings_file_path() -> PathBuf { + if let Ok(dir) = std::env::var("OPENCHAMBER_DATA_DIR") { + if !dir.trim().is_empty() { + return PathBuf::from(dir.trim()).join("settings.json"); + } + } + let home = std::env::var("HOME").unwrap_or_default(); + PathBuf::from(home) + .join(".config") + .join("openchamber") + .join("settings.json") +} + +fn read_settings_root(path: &Path) -> Value { + let raw = fs::read_to_string(path).unwrap_or_default(); + let parsed = serde_json::from_str::(&raw).unwrap_or_else(|_| json!({})); + if parsed.is_object() { + parsed + } else { + json!({}) + } +} + +fn write_settings_root(path: &Path, root: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_string_pretty(root)?)?; + Ok(()) +} + +fn build_display_label(instance: &DesktopSshInstance) -> String { + if let Some(nick) = instance + .nickname + .as_ref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + { + return nick.to_string(); + } + if let Some(parsed) = instance.ssh_parsed.as_ref() { + let destination = parsed.destination.trim(); + if !destination.is_empty() { + return destination.to_string(); + } + } + instance.id.clone() +} + +fn read_desktop_ssh_instances_from_path(path: &Path) -> DesktopSshInstancesConfig { + let root = read_settings_root(path); + let Some(items) = root + .get("desktopSshInstances") + .and_then(Value::as_array) + .cloned() + else { + return DesktopSshInstancesConfig::default(); + }; + + let mut instances = Vec::new(); + let mut seen = HashSet::new(); + for item in items { + let Ok(mut instance) = serde_json::from_value::(item) else { + continue; + }; + + let id = instance.id.trim().to_string(); + if id.is_empty() || id == LOCAL_HOST_ID || seen.contains(&id) { + continue; + } + instance.id = id.clone(); + instance.connection_timeout_sec = if instance.connection_timeout_sec == 0 { + DEFAULT_CONNECTION_TIMEOUT_SEC + } else { + instance.connection_timeout_sec + }; + if instance.local_forward.bind_host.trim().is_empty() { + instance.local_forward.bind_host = default_local_bind_host(); + } + if instance.ssh_parsed.is_none() { + if let Ok(parsed) = parse_ssh_command(&instance.ssh_command) { + instance.ssh_parsed = Some(parsed); + } + } + seen.insert(id); + instances.push(instance); + } + + DesktopSshInstancesConfig { instances } +} + +fn read_desktop_ssh_instances_from_disk() -> DesktopSshInstancesConfig { + read_desktop_ssh_instances_from_path(&settings_file_path()) +} + +fn sanitize_bind_host(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return DEFAULT_LOCAL_BIND_HOST.to_string(); + } + match trimmed { + "127.0.0.1" | "localhost" | "0.0.0.0" => trimmed.to_string(), + _ => DEFAULT_LOCAL_BIND_HOST.to_string(), + } +} + +fn sanitize_forward(forward: &DesktopSshPortForward) -> Option { + let id = forward.id.trim().to_string(); + if id.is_empty() { + return None; + } + + let mut normalized = forward.clone(); + normalized.id = id; + normalized.local_host = normalized + .local_host + .as_ref() + .map(|v| sanitize_bind_host(v)) + .or_else(|| Some(DEFAULT_LOCAL_BIND_HOST.to_string())); + + match normalized.forward_type { + DesktopSshPortForwardType::Local => { + if normalized.local_port.is_none() || normalized.remote_port.is_none() { + return None; + } + if normalized + .remote_host + .as_ref() + .map(|v| v.trim()) + .unwrap_or("") + .is_empty() + { + normalized.remote_host = Some("127.0.0.1".to_string()); + } + } + DesktopSshPortForwardType::Remote => { + if normalized.local_port.is_none() || normalized.remote_port.is_none() { + return None; + } + if normalized + .remote_host + .as_ref() + .map(|v| v.trim()) + .unwrap_or("") + .is_empty() + { + normalized.remote_host = Some("127.0.0.1".to_string()); + } + if normalized + .local_host + .as_ref() + .map(|v| v.trim()) + .unwrap_or("") + .is_empty() + { + normalized.local_host = Some("127.0.0.1".to_string()); + } + } + DesktopSshPortForwardType::Dynamic => { + if normalized.local_port.is_none() { + return None; + } + normalized.remote_host = None; + normalized.remote_port = None; + } + } + + Some(normalized) +} + +fn sanitize_instance(mut instance: DesktopSshInstance) -> Result { + instance.id = instance.id.trim().to_string(); + if instance.id.is_empty() || instance.id == LOCAL_HOST_ID { + return Err(anyhow!("SSH instance id is required")); + } + instance.ssh_command = instance.ssh_command.trim().to_string(); + if instance.ssh_command.is_empty() { + return Err(anyhow!("SSH command is required")); + } + if instance.connection_timeout_sec == 0 { + instance.connection_timeout_sec = DEFAULT_CONNECTION_TIMEOUT_SEC; + } + instance.local_forward.bind_host = sanitize_bind_host(&instance.local_forward.bind_host); + let parsed = parse_ssh_command(&instance.ssh_command)?; + instance.ssh_parsed = Some(parsed); + + let mut seen = HashSet::new(); + let mut forwards = Vec::new(); + for forward in &instance.port_forwards { + let Some(normalized) = sanitize_forward(forward) else { + continue; + }; + if seen.contains(&normalized.id) { + continue; + } + seen.insert(normalized.id.clone()); + forwards.push(normalized); + } + instance.port_forwards = forwards; + + Ok(instance) +} + +fn sync_desktop_hosts_for_ssh( + root: &mut Value, + previous_ids: &HashSet, + instances: &[DesktopSshInstance], +) { + let next_ids: HashSet = instances.iter().map(|item| item.id.clone()).collect(); + + let mut hosts = root + .get("desktopHosts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + hosts.retain(|entry| { + let id = entry + .get("id") + .and_then(Value::as_str) + .map(|value| value.trim()) + .unwrap_or(""); + if id.is_empty() { + return false; + } + !(previous_ids.contains(id) && !next_ids.contains(id)) + }); + + for instance in instances { + let label = build_display_label(instance); + let mut found = false; + for host in &mut hosts { + let host_id = host + .get("id") + .and_then(Value::as_str) + .map(|value| value.trim()) + .unwrap_or(""); + if host_id != instance.id { + continue; + } + if let Some(obj) = host.as_object_mut() { + obj.insert("id".to_string(), Value::String(instance.id.clone())); + obj.insert("label".to_string(), Value::String(label.clone())); + let should_set_default_url = obj + .get("url") + .and_then(Value::as_str) + .map(|value| value.trim().is_empty()) + .unwrap_or(true); + if should_set_default_url { + obj.insert( + "url".to_string(), + Value::String("http://127.0.0.1/".to_string()), + ); + } + } + found = true; + break; + } + + if !found { + hosts.push(json!({ + "id": instance.id, + "label": label, + "url": "http://127.0.0.1/" + })); + } + } + + root["desktopHosts"] = Value::Array(hosts); + + let default_id = root + .get("desktopDefaultHostId") + .and_then(Value::as_str) + .map(|value| value.trim().to_string()) + .unwrap_or_default(); + if !default_id.is_empty() + && previous_ids.contains(default_id.as_str()) + && !next_ids.contains(default_id.as_str()) + { + root["desktopDefaultHostId"] = Value::String(LOCAL_HOST_ID.to_string()); + } +} + +fn write_desktop_ssh_instances_to_path( + path: &Path, + config: DesktopSshInstancesConfig, +) -> Result { + let mut root = read_settings_root(path); + let previous = read_desktop_ssh_instances_from_path(path); + let previous_ids: HashSet = previous + .instances + .iter() + .map(|instance| instance.id.clone()) + .collect(); + + let mut seen = HashSet::new(); + let mut sanitized = Vec::new(); + + for instance in config.instances { + let normalized = sanitize_instance(instance)?; + if seen.contains(&normalized.id) { + continue; + } + seen.insert(normalized.id.clone()); + sanitized.push(normalized); + } + + sync_desktop_hosts_for_ssh(&mut root, &previous_ids, &sanitized); + root["desktopSshInstances"] = serde_json::to_value(&sanitized)?; + write_settings_root(path, &root)?; + + Ok(DesktopSshInstancesConfig { + instances: sanitized, + }) +} + +fn update_ssh_host_url(instance_id: &str, label: &str, local_url: &str) -> Result<()> { + let path = settings_file_path(); + let mut root = read_settings_root(&path); + let mut hosts = root + .get("desktopHosts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut found = false; + for host in &mut hosts { + let host_id = host + .get("id") + .and_then(Value::as_str) + .map(|value| value.trim()) + .unwrap_or(""); + if host_id != instance_id { + continue; + } + if let Some(obj) = host.as_object_mut() { + obj.insert("id".to_string(), Value::String(instance_id.to_string())); + obj.insert("label".to_string(), Value::String(label.to_string())); + obj.insert("url".to_string(), Value::String(local_url.to_string())); + found = true; + break; + } + } + + if !found { + hosts.push(json!({ + "id": instance_id, + "label": label, + "url": local_url + })); + } + + root["desktopHosts"] = Value::Array(hosts); + write_settings_root(&path, &root) +} + +fn persist_local_port_for_instance(instance_id: &str, local_port: u16) -> Result<()> { + let path = settings_file_path(); + let mut root = read_settings_root(&path); + let mut changed = false; + + if let Some(items) = root + .get_mut("desktopSshInstances") + .and_then(Value::as_array_mut) + { + for item in items { + let Some(id) = item.get("id").and_then(Value::as_str) else { + continue; + }; + if id.trim() != instance_id { + continue; + } + if item + .get("localForward") + .and_then(Value::as_object) + .is_none() + { + item["localForward"] = json!({}); + } + item["localForward"]["preferredLocalPort"] = Value::Number(local_port.into()); + changed = true; + break; + } + } + + if changed { + write_settings_root(&path, &root)?; + } + + Ok(()) +} + +fn split_shell_words(input: &str) -> Result> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + let mut in_single = false; + let mut in_double = false; + + while let Some(ch) = chars.next() { + match ch { + '\\' if !in_single => { + if let Some(next) = chars.next() { + current.push(next); + } + } + '\'' if !in_double => { + in_single = !in_single; + } + '"' if !in_single => { + in_double = !in_double; + } + c if c.is_whitespace() && !in_single && !in_double => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + + if in_single || in_double { + return Err(anyhow!("Unclosed quote in SSH command")); + } + + if !current.is_empty() { + tokens.push(current); + } + + Ok(tokens) +} + +fn is_disallowed_primary_flag(token: &str) -> bool { + const DISALLOWED: [&str; 17] = [ + "-M", "-S", "-O", "-N", "-t", "-T", "-f", "-G", "-W", "-v", "-V", "-q", "-n", "-s", "-e", + "-E", "-g", + ]; + DISALLOWED.contains(&token) +} + +fn has_disallowed_o_option(value: &str) -> bool { + let lower = value.trim().to_ascii_lowercase(); + [ + "controlmaster", + "controlpath", + "controlpersist", + "batchmode", + "proxycommand", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) +} + +fn parse_ssh_command(raw: &str) -> Result { + let mut tokens = split_shell_words(raw)?; + if tokens.is_empty() { + return Err(anyhow!("SSH command is empty")); + } + + if tokens[0] == "ssh" { + tokens.remove(0); + } + + if tokens.is_empty() { + return Err(anyhow!("SSH command must include destination")); + } + + const ALLOWED_FLAGS: [&str; 11] = [ + "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", + ]; + const ALLOWED_WITH_VALUES: [&str; 14] = [ + "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", + ]; + + let mut destination: Option = None; + let mut args = Vec::new(); + let mut idx = 0usize; + + while idx < tokens.len() { + let token = tokens[idx].clone(); + if destination.is_some() { + return Err(anyhow!( + "SSH command has unsupported trailing argument: {token}" + )); + } + + if token.starts_with('-') { + if is_disallowed_primary_flag(token.as_str()) { + return Err(anyhow!("SSH option {token} is not allowed")); + } + + if ALLOWED_FLAGS.contains(&token.as_str()) { + args.push(token); + idx += 1; + continue; + } + + let mut matched = false; + for option in ALLOWED_WITH_VALUES { + if token == option { + if idx + 1 >= tokens.len() { + return Err(anyhow!("SSH option {option} requires a value")); + } + let value = tokens[idx + 1].clone(); + if option == "-o" && has_disallowed_o_option(&value) { + return Err(anyhow!("SSH option -o {value} is not allowed")); + } + args.push(token.clone()); + args.push(value); + idx += 2; + matched = true; + break; + } + + if token.starts_with(option) && token.len() > option.len() { + let value = token[option.len()..].to_string(); + if option == "-o" && has_disallowed_o_option(&value) { + return Err(anyhow!("SSH option -o {value} is not allowed")); + } + args.push(token.clone()); + idx += 1; + matched = true; + break; + } + } + + if !matched { + return Err(anyhow!("Unsupported SSH option: {token}")); + } + + continue; + } + + destination = Some(token); + idx += 1; + } + + let Some(destination) = destination + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()) + else { + return Err(anyhow!("SSH command must include destination")); + }; + + Ok(DesktopSshParsedCommand { destination, args }) +} + +fn shell_quote(value: &str) -> String { + let escaped = value.replace('\'', "'\\''"); + format!("'{escaped}'") +} + +fn run_output(command: &mut Command) -> Result<(i32, String, String)> { + let output = command + .output() + .with_context(|| format!("failed to execute command: {:?}", command))?; + + let code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok((code, stdout, stderr)) +} + +fn build_ssh_command( + parsed: &DesktopSshParsedCommand, + pre_destination_args: &[String], + remote_command: Option<&str>, +) -> Command { + let mut command = Command::new("ssh"); + command + .args(&parsed.args) + .args(pre_destination_args) + .arg(&parsed.destination); + if let Some(remote) = remote_command { + command.arg(remote); + } + command +} + +fn resolve_ssh_config(parsed: &DesktopSshParsedCommand) -> Result> { + let args = vec!["-G".to_string()]; + let mut command = build_ssh_command(parsed, &args, None); + let (code, stdout, stderr) = run_output(&mut command)?; + if code != 0 { + return Err(anyhow!(stderr.trim().to_string())); + } + + let mut resolved = HashMap::new(); + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let mut parts = trimmed.splitn(2, ' '); + let key = parts.next().unwrap_or_default().trim().to_ascii_lowercase(); + let value = parts.next().unwrap_or_default().trim(); + if key.is_empty() || value.is_empty() { + continue; + } + resolved.insert(key, value.to_string()); + } + Ok(resolved) +} + +fn ensure_session_dir(instance_id: &str) -> Result { + let base = settings_file_path() + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("ssh") + .join(instance_id); + fs::create_dir_all(&base)?; + Ok(base) +} + +fn control_path_for_instance(_session_dir: &Path, instance_id: &str) -> PathBuf { + let hash = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + instance_id.hash(&mut hasher); + hasher.finish() + }; + std::env::temp_dir().join(format!("ocssh-{hash:x}.sock")) +} + +fn askpass_script_content() -> String { + let script = r#"#!/bin/bash +PROMPT="$1" + +if [[ -n "$OPENCHAMBER_SSH_ASKPASS_VALUE" ]]; then + if [[ "$PROMPT" == *"assword"* || "$PROMPT" == *"passphrase"* ]]; then + printf '%s\n' "$OPENCHAMBER_SSH_ASKPASS_VALUE" + exit 0 + fi +fi + +DEFAULT_ANSWER="" +HIDDEN_INPUT="true" + +if [[ "$PROMPT" == *"yes/no"* ]]; then + DEFAULT_ANSWER="yes" + HIDDEN_INPUT="false" +fi + +/usr/bin/osascript <<'APPLESCRIPT' "$PROMPT" "$DEFAULT_ANSWER" "$HIDDEN_INPUT" +on run argv + set promptText to item 1 of argv + set defaultAnswer to item 2 of argv + set hiddenInput to item 3 of argv + + try + if hiddenInput is "true" then + set response to display dialog promptText default answer defaultAnswer with hidden answer buttons {"Cancel", "OK"} default button "OK" + else + set response to display dialog promptText default answer defaultAnswer buttons {"Cancel", "OK"} default button "OK" + end if + return text returned of response + on error + error number -128 + end try +end run +APPLESCRIPT +"#; + script.to_string() +} + +fn write_askpass_script(path: &Path) -> Result<()> { + fs::write(path, askpass_script_content())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = fs::metadata(path)?.permissions(); + perm.set_mode(0o700); + fs::set_permissions(path, perm)?; + } + Ok(()) +} + +fn spawn_master_process( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + askpass_path: &Path, + ssh_password: Option<&str>, +) -> Result { + let args = vec![ + "-o".to_string(), + "ControlMaster=yes".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-o".to_string(), + format!("ControlPersist={DEFAULT_CONTROL_PERSIST_SEC}"), + "-N".to_string(), + ]; + let mut command = build_ssh_command(parsed, &args, None); + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("SSH_ASKPASS_REQUIRE", "force") + .env("SSH_ASKPASS", askpass_path) + .env("DISPLAY", "1"); + + if let Some(secret) = ssh_password.filter(|value| !value.trim().is_empty()) { + command.env("OPENCHAMBER_SSH_ASKPASS_VALUE", secret.trim()); + } + + command.spawn().with_context(|| { + format!( + "failed to start SSH ControlMaster for {}", + parsed.destination + ) + }) +} + +fn wait_for_master_ready( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + timeout_sec: u16, + master: &mut Child, +) -> Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_sec as u64); + while std::time::Instant::now() < deadline { + let args = vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-O".to_string(), + "check".to_string(), + ]; + + let mut check = build_ssh_command(parsed, &args, None); + let (code, _stdout, _stderr) = run_output(&mut check)?; + if code == 0 { + return Ok(()); + } + + if let Some(status) = master.try_wait().ok().flatten() { + let mut stderr = String::new(); + if let Some(mut stream) = master.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + if stderr.trim().is_empty() { + return Err(anyhow!(format!( + "SSH master process exited before ready (status: {status})" + ))); + } + return Err(anyhow!(stderr.trim().to_string())); + } + + std::thread::sleep(Duration::from_millis(250)); + } + + Err(anyhow!("SSH ControlMaster connection timed out")) +} + +fn control_master_operation( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + op: &str, +) -> Result<(i32, String, String)> { + let args = vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + "ConnectTimeout=3".to_string(), + "-O".to_string(), + op.to_string(), + ]; + let mut command = build_ssh_command(parsed, &args, None); + run_output(&mut command) +} + +fn is_control_master_alive(parsed: &DesktopSshParsedCommand, control_path: &Path) -> bool { + control_master_operation(parsed, control_path, "check") + .map(|(code, _, _)| code == 0) + .unwrap_or(false) +} + +fn stop_control_master_best_effort(parsed: &DesktopSshParsedCommand, control_path: &Path) { + let _ = control_master_operation(parsed, control_path, "exit"); +} + +fn run_remote_command( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + script: &str, + timeout_sec: u16, +) -> Result { + let args = vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-o".to_string(), + format!("ConnectTimeout={timeout_sec}"), + "-T".to_string(), + ]; + let remote = format!("sh -lc {}", shell_quote(script)); + let mut command = build_ssh_command(parsed, &args, Some(&remote)); + let (code, stdout, stderr) = run_output(&mut command)?; + if code != 0 { + if stderr.trim().is_empty() { + return Err(anyhow!("Remote command failed")); + } + return Err(anyhow!(stderr.trim().to_string())); + } + Ok(stdout) +} + +fn remote_command_exists( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + command_name: &str, +) -> bool { + run_remote_command( + parsed, + control_path, + &format!( + "command -v {} >/dev/null 2>&1 && echo yes || echo no", + command_name + ), + DEFAULT_CONNECTION_TIMEOUT_SEC, + ) + .map(|output| output.trim() == "yes") + .unwrap_or(false) +} + +fn parse_version_token(raw: &str) -> Option { + for token in raw.split_whitespace() { + let mut candidate = token.trim().trim_start_matches('v').to_string(); + while candidate.ends_with(',') || candidate.ends_with(')') || candidate.ends_with('(') { + candidate.pop(); + } + let parts: Vec<&str> = candidate.split('.').collect(); + if parts.len() < 2 { + continue; + } + if parts + .iter() + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) + { + return Some(candidate); + } + } + None +} + +fn current_remote_openchamber_version( + parsed: &DesktopSshParsedCommand, + control_path: &Path, +) -> Option { + run_remote_command( + parsed, + control_path, + "openchamber --version 2>/dev/null || true", + DEFAULT_CONNECTION_TIMEOUT_SEC, + ) + .ok() + .and_then(|value| parse_version_token(&value)) +} + +fn install_openchamber_managed( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + version: &str, + preferred: &DesktopSshInstallMethod, +) -> Result<()> { + let has_bun = remote_command_exists(parsed, control_path, "bun"); + let has_npm = remote_command_exists(parsed, control_path, "npm"); + + let mut commands = Vec::new(); + + match preferred { + DesktopSshInstallMethod::Bun => { + if has_bun { + commands.push(format!("bun add -g @openchamber/web@{version}")); + } + if has_npm { + commands.push(format!("npm install -g @openchamber/web@{version}")); + } + } + DesktopSshInstallMethod::Npm => { + if has_npm { + commands.push(format!("npm install -g @openchamber/web@{version}")); + } + if has_bun { + commands.push(format!("bun add -g @openchamber/web@{version}")); + } + } + DesktopSshInstallMethod::DownloadRelease | DesktopSshInstallMethod::UploadBundle => { + if has_bun { + commands.push(format!("bun add -g @openchamber/web@{version}")); + } + if has_npm { + commands.push(format!("npm install -g @openchamber/web@{version}")); + } + } + } + + if commands.is_empty() { + return Err(anyhow!("Remote host has neither bun nor npm available")); + } + + let mut last_error: Option = None; + for command in commands { + match run_remote_command( + parsed, + control_path, + &command, + DEFAULT_CONNECTION_TIMEOUT_SEC, + ) { + Ok(_) => return Ok(()), + Err(err) => { + last_error = Some(err); + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("Failed to install OpenChamber on remote host"))) +} + +fn parse_probe_status_line(line: Option<&str>, prefix: &str) -> Option { + let value = line?.strip_prefix(prefix)?.trim(); + value.parse::().ok() +} + +fn is_auth_http_status(status: u16) -> bool { + status == 401 || status == 403 +} + +fn is_liveness_http_status(status: u16) -> bool { + (200..=299).contains(&status) || is_auth_http_status(status) +} + +fn configured_openchamber_password(instance: &DesktopSshInstance) -> Option<&str> { + instance + .auth + .openchamber_password + .as_ref() + .and_then(|secret| { + if secret.enabled { + secret.value.as_deref() + } else { + None + } + }) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn probe_remote_system_info( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + port: u16, + openchamber_password: Option<&str>, +) -> Result { + let auth_payload = if let Some(password) = openchamber_password { + serde_json::to_string(&json!({ "password": password })).unwrap_or_else(|_| "{}".to_string()) + } else { + "{}".to_string() + }; + + let auth_enabled = if openchamber_password.is_some() { + "1" + } else { + "0" + }; + let script = format!( + "AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE=\"$(mktemp)\"; COOKIE_FILE=\"$(mktemp)\"; cleanup() {{ rm -f \"$BODY_FILE\" \"$COOKIE_FILE\"; }}; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ \"{auth_enabled}\" = \"1\" ]; then AUTH_STATUS=\"$(curl -sS --max-time 3 -o /dev/null -w '%{{http_code}}' -c \"$COOKIE_FILE\" -H 'content-type: application/json' --data {auth_payload} http://127.0.0.1:{port}/auth/session || true)\"; if [ \"$AUTH_STATUS\" = \"200\" ]; then INFO_STATUS=\"$(curl -sS --max-time 3 -b \"$COOKIE_FILE\" -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; else INFO_STATUS=\"$(curl -sS --max-time 3 -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; fi; else INFO_STATUS=\"$(curl -sS --max-time 3 -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; fi; HEALTH_STATUS=\"$(curl -sS --max-time 3 -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{port}/health || true)\"; elif command -v wget >/dev/null 2>&1; then wget -qO \"$BODY_FILE\" http://127.0.0.1:{port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:{port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' \"$INFO_STATUS\" \"$AUTH_STATUS\" \"$HEALTH_STATUS\"; cat \"$BODY_FILE\" 2>/dev/null || true", + auth_payload = shell_quote(&auth_payload), + ); + let output = run_remote_command( + parsed, + control_path, + &script, + DEFAULT_CONNECTION_TIMEOUT_SEC, + )?; + + let mut lines = output.lines(); + let info_status = parse_probe_status_line(lines.next(), "INFO_STATUS=").unwrap_or(0); + let auth_status = parse_probe_status_line(lines.next(), "AUTH_STATUS=").unwrap_or(0); + let health_status = parse_probe_status_line(lines.next(), "HEALTH_STATUS=").unwrap_or(0); + let body = lines.collect::>().join("\n"); + + if is_liveness_http_status(info_status) { + if is_auth_http_status(info_status) { + if openchamber_password.is_some() && auth_status != 200 { + return Err(anyhow!(format!( + "Remote OpenChamber requires UI authentication and configured password was rejected (auth status {auth_status})" + ))); + } + + if is_liveness_http_status(health_status) { + return Ok(RemoteSystemInfo::default()); + } + + return Err(anyhow!( + "Remote OpenChamber requires UI authentication on /api/system/info; configure OpenChamber UI password" + )); + } + } else if is_liveness_http_status(health_status) { + return Ok(RemoteSystemInfo::default()); + } else { + return Err(anyhow!(format!( + "Remote OpenChamber probe failed (info status {info_status}, health status {health_status})" + ))); + } + + let mut info = serde_json::from_str::(&body).unwrap_or_default(); + if info.openchamber_version.is_none() { + if let Ok(value) = serde_json::from_str::(&body) { + info.openchamber_version = value + .get("openchamberVersion") + .and_then(Value::as_str) + .map(|v| v.to_string()); + info.runtime = value + .get("runtime") + .and_then(Value::as_str) + .map(|v| v.to_string()); + info.pid = value.get("pid").and_then(Value::as_u64); + info.started_at = value + .get("startedAt") + .and_then(Value::as_str) + .map(|v| v.to_string()); + } + } + Ok(info) +} + +fn remote_server_running( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + port: u16, + openchamber_password: Option<&str>, +) -> bool { + probe_remote_system_info(parsed, control_path, port, openchamber_password).is_ok() +} + +fn random_port_candidate(seed: &str) -> u16 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + seed.hash(&mut hasher); + now_millis().hash(&mut hasher); + let value = hasher.finish(); + let base = 20_000u16; + let span = 30_000u16; + base + ((value % span as u64) as u16) +} + +fn start_remote_server_managed( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + instance: &DesktopSshInstance, + desired_port: u16, +) -> Result { + let mut env_prefix = "OPENCHAMBER_RUNTIME=ssh-remote".to_string(); + if let Some(secret) = instance + .auth + .openchamber_password + .as_ref() + .and_then(|v| if v.enabled { v.value.clone() } else { None }) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + env_prefix.push(' '); + env_prefix.push_str("OPENCHAMBER_UI_PASSWORD="); + env_prefix.push_str(&shell_quote(&secret)); + } + let script = format!( + "{env_prefix} openchamber serve --daemon --hostname 127.0.0.1 --port {desired_port}" + ); + let output = run_remote_command( + parsed, + control_path, + &script, + DEFAULT_CONNECTION_TIMEOUT_SEC, + )?; + + if let Some(port) = output + .split_whitespace() + .find_map(|token| token.parse::().ok()) + { + return Ok(port); + } + Ok(desired_port) +} + +fn stop_remote_server_best_effort( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + remote_port: u16, +) { + let script = format!( + "if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:{remote_port}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:{remote_port}/api/system/shutdown >/dev/null 2>&1 || true; fi" + ); + let _ = run_remote_command( + parsed, + control_path, + &script, + DEFAULT_CONNECTION_TIMEOUT_SEC, + ); +} + +fn spawn_main_forward( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + bind_host: &str, + local_port: u16, + remote_port: u16, +) -> Result { + let args = vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-N".to_string(), + "-L".to_string(), + format!("{bind_host}:{local_port}:127.0.0.1:{remote_port}"), + ]; + let mut command = build_ssh_command(parsed, &args, None); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to start main SSH forward on local port {local_port}")) +} + +fn spawn_extra_forward( + parsed: &DesktopSshParsedCommand, + control_path: &Path, + forward: &DesktopSshPortForward, +) -> Result<()> { + let mut args = vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-O".to_string(), + "forward".to_string(), + ]; + + match forward.forward_type { + DesktopSshPortForwardType::Local => { + let local_host = forward + .local_host + .as_deref() + .filter(|v| !v.trim().is_empty()) + .unwrap_or("127.0.0.1"); + let local_port = forward + .local_port + .ok_or_else(|| anyhow!("Missing local port"))?; + let remote_host = forward + .remote_host + .as_deref() + .filter(|v| !v.trim().is_empty()) + .unwrap_or("127.0.0.1"); + let remote_port = forward + .remote_port + .ok_or_else(|| anyhow!("Missing remote port"))?; + args.push("-L".to_string()); + args.push(format!( + "{local_host}:{local_port}:{remote_host}:{remote_port}" + )); + } + DesktopSshPortForwardType::Remote => { + let remote_host = forward + .remote_host + .as_deref() + .filter(|v| !v.trim().is_empty()) + .unwrap_or("127.0.0.1"); + let remote_port = forward + .remote_port + .ok_or_else(|| anyhow!("Missing remote port"))?; + let local_host = forward + .local_host + .as_deref() + .filter(|v| !v.trim().is_empty()) + .unwrap_or("127.0.0.1"); + let local_port = forward + .local_port + .ok_or_else(|| anyhow!("Missing local port"))?; + args.push("-R".to_string()); + args.push(format!( + "{remote_host}:{remote_port}:{local_host}:{local_port}" + )); + } + DesktopSshPortForwardType::Dynamic => { + let local_host = forward + .local_host + .as_deref() + .filter(|v| !v.trim().is_empty()) + .unwrap_or("127.0.0.1"); + let local_port = forward + .local_port + .ok_or_else(|| anyhow!("Missing local port"))?; + args.push("-D".to_string()); + args.push(format!("{local_host}:{local_port}")); + } + } + + let mut command = build_ssh_command(parsed, &args, None); + let (code, stdout, stderr) = run_output(&mut command) + .with_context(|| format!("Failed to configure extra SSH forward {}", forward.id))?; + if code != 0 { + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + return Err(anyhow!(format!( + "Failed to configure extra SSH forward {}: {}", + forward.id, + if detail.is_empty() { + "unknown error" + } else { + detail + } + ))); + } + Ok(()) +} + +fn is_local_port_available(bind_host: &str, port: u16) -> bool { + TcpListener::bind(format!("{bind_host}:{port}")).is_ok() +} + +fn pick_unused_local_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + Ok(listener.local_addr()?.port()) +} + +fn is_local_tunnel_reachable(local_port: u16) -> bool { + let addr = format!("127.0.0.1:{local_port}"); + let Ok(parsed) = addr.parse() else { + return false; + }; + TcpStream::connect_timeout(&parsed, Duration::from_millis(500)).is_ok() +} + +fn wait_local_forward_ready(local_port: u16) -> Result<()> { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_millis(1000)) + .no_proxy() + .build()?; + let deadline = std::time::Instant::now() + Duration::from_secs(DEFAULT_READY_TIMEOUT_SEC); + let target = format!("http://127.0.0.1:{local_port}/health"); + while std::time::Instant::now() < deadline { + if let Ok(response) = client.get(&target).send() { + if response.status().is_success() || response.status().as_u16() == 401 { + return Ok(()); + } + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(anyhow!( + "Timed out waiting for forwarded OpenChamber health" + )) +} + +fn kill_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn parse_ssh_config_candidates(path: &Path, source: &str) -> Vec { + let Ok(content) = fs::read_to_string(path) else { + return Vec::new(); + }; + let mut candidates = Vec::new(); + for line in content.lines() { + let trimmed = line.split('#').next().map(|part| part.trim()).unwrap_or(""); + if trimmed.is_empty() { + continue; + } + if trimmed.len() < 4 { + continue; + } + if !trimmed[..4].eq_ignore_ascii_case("host") { + continue; + } + + let rest = trimmed[4..].trim(); + if rest.is_empty() { + continue; + } + + for token in rest.split_whitespace() { + let host = token.trim(); + if host.is_empty() || host.starts_with('!') { + continue; + } + if host == "*" { + continue; + } + let pattern = host.contains('*') || host.contains('?'); + candidates.push(DesktopSshImportCandidate { + host: host.to_string(), + pattern, + source: source.to_string(), + ssh_command: format!("ssh {host}"), + }); + } + } + candidates +} + +impl DesktopSshManagerInner { + fn append_log_with_level(&self, id: &str, level: &str, message: impl Into) { + let line = format!("[{}] [{}] {}", now_millis(), level, message.into()); + let mut logs = self.logs.lock().expect("ssh logs mutex"); + let entry = logs.entry(id.to_string()).or_default(); + entry.push(line); + if entry.len() > MAX_LOG_LINES_PER_INSTANCE { + let overflow = entry.len() - MAX_LOG_LINES_PER_INSTANCE; + entry.drain(0..overflow); + } + } + + fn append_log(&self, id: &str, message: impl Into) { + self.append_log_with_level(id, "INFO", message); + } + + fn append_attempt_separator(&self, id: &str, connect_attempt: u32, retry_attempt: u32) { + let scope = if retry_attempt > 0 { + format!("retry {retry_attempt}") + } else { + "manual".to_string() + }; + self.append_log_with_level( + id, + "INFO", + format!("---------------- attempt #{connect_attempt} ({scope}) ----------------"), + ); + } + + fn logs_for_instance(&self, id: &str, limit: usize) -> Vec { + let logs = self.logs.lock().expect("ssh logs mutex"); + let mut lines = logs.get(id).cloned().unwrap_or_default(); + if limit > 0 && lines.len() > limit { + let keep_from = lines.len() - limit; + lines.drain(0..keep_from); + } + lines + } + + fn clear_logs_for_instance(&self, id: &str) { + self.logs.lock().expect("ssh logs mutex").remove(id); + } + + fn status_snapshot_for_instance(&self, id: &str) -> DesktopSshInstanceStatus { + self.statuses + .lock() + .expect("ssh status mutex") + .get(id) + .cloned() + .unwrap_or_else(|| DesktopSshInstanceStatus::idle(id)) + } + + fn set_status( + &self, + app: &AppHandle, + id: &str, + phase: DesktopSshPhase, + detail: Option, + local_url: Option, + local_port: Option, + remote_port: Option, + started_by_us: bool, + retry_attempt: u32, + requires_user_action: bool, + ) { + let level = if matches!(&phase, DesktopSshPhase::Error) { + "ERROR" + } else if matches!(&phase, DesktopSshPhase::Degraded) { + "WARN" + } else { + "INFO" + }; + + self.append_log_with_level( + id, + level, + format!( + "phase={} detail={} retry={} requires_user_action={}", + serde_json::to_string(&phase).unwrap_or_else(|_| "\"unknown\"".to_string()), + detail.as_deref().unwrap_or(""), + retry_attempt, + requires_user_action + ), + ); + + let status = DesktopSshInstanceStatus { + id: id.to_string(), + phase, + detail, + local_url, + local_port, + remote_port, + started_by_us, + retry_attempt, + requires_user_action, + updated_at_ms: now_millis(), + }; + + self.statuses + .lock() + .expect("ssh status mutex") + .insert(id.to_string(), status.clone()); + let _ = app.emit(SSH_STATUS_EVENT, status); + } + + fn clear_retry_attempt(&self, id: &str) { + self.reconnect_attempts + .lock() + .expect("ssh retry mutex") + .remove(id); + } + + fn next_retry_attempt(&self, id: &str) -> u32 { + let mut guard = self.reconnect_attempts.lock().expect("ssh retry mutex"); + let next = guard.get(id).copied().unwrap_or(0).saturating_add(1); + guard.insert(id.to_string(), next); + next + } + + fn current_retry_attempt(&self, id: &str) -> u32 { + self.reconnect_attempts + .lock() + .expect("ssh retry mutex") + .get(id) + .copied() + .unwrap_or(0) + } + + fn next_connect_attempt(&self, id: &str) -> u32 { + let mut guard = self + .connect_attempts + .lock() + .expect("ssh connect-attempt mutex"); + let next = guard.get(id).copied().unwrap_or(0).saturating_add(1); + guard.insert(id.to_string(), next); + next + } + + fn cancel_connect_task(&self, id: &str) { + if let Some(handle) = self + .connect_tasks + .lock() + .expect("ssh connect task mutex") + .remove(id) + { + handle.abort(); + } + } + + fn cancel_monitor_task(&self, id: &str) { + if let Some(handle) = self + .monitor_tasks + .lock() + .expect("ssh monitor task mutex") + .remove(id) + { + handle.abort(); + } + } + + fn session_is_alive(&self, id: &str) -> bool { + let mut sessions = self.sessions.lock().expect("ssh sessions mutex"); + let Some(session) = sessions.get_mut(id) else { + return false; + }; + + let mut main_anchor_alive = false; + + if !session.main_forward_detached { + if let Some(status) = session.main_forward.try_wait().ok().flatten() { + if status.success() { + session.main_forward_detached = true; + self.append_log_with_level( + id, + "INFO", + "Main tunnel helper exited after ControlMaster handoff", + ); + } else { + let mut stderr = String::new(); + if let Some(mut stream) = session.main_forward.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + self.append_log_with_level( + id, + "WARN", + if stderr.trim().is_empty() { + format!("Existing main SSH forward is not running ({status})") + } else { + format!( + "Existing main SSH forward is not running ({status}): {}", + stderr.trim() + ) + }, + ); + return false; + } + } else { + main_anchor_alive = true; + } + } + + if main_anchor_alive { + return true; + } + + if session.master_detached { + if !is_control_master_alive(&session.parsed, &session.control_path) { + if is_local_tunnel_reachable(session.local_port) { + self.append_log_with_level( + id, + "WARN", + "SSH ControlMaster check failed but local tunnel is still reachable", + ); + return true; + } + self.append_log_with_level( + id, + "WARN", + "Existing SSH ControlMaster is not reachable", + ); + return false; + } + } else if let Some(status) = session.master.try_wait().ok().flatten() { + if status.success() && is_control_master_alive(&session.parsed, &session.control_path) { + session.master_detached = true; + self.append_log_with_level( + id, + "INFO", + "SSH ControlMaster transitioned to detached background mode", + ); + } else { + let mut stderr = String::new(); + if let Some(mut stream) = session.master.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + self.append_log_with_level( + id, + "WARN", + if stderr.trim().is_empty() { + format!("Existing SSH ControlMaster is not running ({status})") + } else { + format!( + "Existing SSH ControlMaster is not running ({status}): {}", + stderr.trim() + ) + }, + ); + return false; + } + } + + true + } + + fn disconnect_internal(&self, app: &AppHandle, id: &str, report_idle: bool) { + self.cancel_connect_task(id); + self.cancel_monitor_task(id); + + if let Some(mut session) = self.sessions.lock().expect("ssh sessions mutex").remove(id) { + if session.started_by_us + && matches!( + session.instance.remote_openchamber.mode, + DesktopSshRemoteMode::Managed + ) + && !session.instance.remote_openchamber.keep_running + { + stop_remote_server_best_effort( + &session.parsed, + &session.control_path, + session.remote_port, + ); + } + + stop_control_master_best_effort(&session.parsed, &session.control_path); + + kill_child(&mut session.main_forward); + for child in &mut session.extra_forwards { + kill_child(child); + } + kill_child(&mut session.master); + + let _ = fs::remove_file(&session.control_path); + let _ = fs::remove_file(session.session_dir.join("askpass.sh")); + } + + self.clear_retry_attempt(id); + + if report_idle { + self.set_status( + app, + id, + DesktopSshPhase::Idle, + None, + None, + None, + None, + false, + 0, + false, + ); + } + } + + fn ensure_remote_server( + &self, + app: &AppHandle, + instance: &DesktopSshInstance, + parsed: &DesktopSshParsedCommand, + control_path: &Path, + ) -> Result<(u16, bool)> { + let app_version = app.package_info().version.to_string(); + + match instance.remote_openchamber.mode { + DesktopSshRemoteMode::External => { + let Some(port) = instance.remote_openchamber.preferred_port else { + return Err(anyhow!( + "External mode requires a preferred remote OpenChamber port" + )); + }; + self.set_status( + app, + &instance.id, + DesktopSshPhase::ServerDetecting, + Some("Probing external OpenChamber server".to_string()), + None, + None, + Some(port), + false, + 0, + false, + ); + probe_remote_system_info( + parsed, + control_path, + port, + configured_openchamber_password(instance), + ) + .map_err(|err| { + anyhow!(format!( + "External OpenChamber server probe failed on configured remote port: {err}" + )) + })?; + Ok((port, false)) + } + DesktopSshRemoteMode::Managed => { + self.set_status( + app, + &instance.id, + DesktopSshPhase::RemoteProbe, + Some("Checking remote OpenChamber installation".to_string()), + None, + None, + None, + false, + 0, + false, + ); + + let installed_version = current_remote_openchamber_version(parsed, control_path); + if installed_version.is_none() { + self.set_status( + app, + &instance.id, + DesktopSshPhase::Installing, + Some("Installing OpenChamber on remote host".to_string()), + None, + None, + None, + false, + 0, + false, + ); + install_openchamber_managed( + parsed, + control_path, + &app_version, + &instance.remote_openchamber.install_method, + )?; + } else if installed_version.as_deref() != Some(app_version.as_str()) { + self.set_status( + app, + &instance.id, + DesktopSshPhase::Updating, + Some(format!( + "Updating remote OpenChamber from {} to {}", + installed_version + .clone() + .unwrap_or_else(|| "unknown".to_string()), + app_version + )), + None, + None, + None, + false, + 0, + false, + ); + install_openchamber_managed( + parsed, + control_path, + &app_version, + &instance.remote_openchamber.install_method, + )?; + } + + self.set_status( + app, + &instance.id, + DesktopSshPhase::ServerDetecting, + Some("Detecting managed OpenChamber server".to_string()), + None, + None, + None, + false, + 0, + false, + ); + + let mut started_by_us = false; + let mut remote_port = instance.remote_openchamber.preferred_port; + + if let Some(port) = remote_port { + if !remote_server_running( + parsed, + control_path, + port, + configured_openchamber_password(instance), + ) { + remote_port = None; + } + } + + if remote_port.is_none() { + self.set_status( + app, + &instance.id, + DesktopSshPhase::ServerStarting, + Some("Starting managed OpenChamber server".to_string()), + None, + None, + None, + false, + 0, + false, + ); + let desired_port = instance + .remote_openchamber + .preferred_port + .unwrap_or_else(|| random_port_candidate(&instance.id)); + let started_port = + start_remote_server_managed(parsed, control_path, instance, desired_port)?; + remote_port = Some(started_port); + started_by_us = true; + } + + let Some(port) = remote_port else { + return Err(anyhow!("Failed to determine remote OpenChamber port")); + }; + + if !remote_server_running( + parsed, + control_path, + port, + configured_openchamber_password(instance), + ) { + return Err(anyhow!( + "Managed OpenChamber server failed to become reachable" + )); + } + + Ok((port, started_by_us)) + } + } + } + + fn connect_blocking( + self: &Arc, + app: &AppHandle, + instance: DesktopSshInstance, + ) -> Result<()> { + let id = instance.id.clone(); + self.set_status( + app, + &id, + DesktopSshPhase::ConfigResolved, + Some("Resolving SSH command".to_string()), + None, + None, + None, + false, + 0, + false, + ); + + let parsed = instance + .ssh_parsed + .clone() + .or_else(|| parse_ssh_command(&instance.ssh_command).ok()) + .ok_or_else(|| anyhow!("Invalid SSH command"))?; + + let _resolved = resolve_ssh_config(&parsed)?; + + self.set_status( + app, + &id, + DesktopSshPhase::AuthCheck, + Some("Checking SSH connectivity".to_string()), + None, + None, + None, + false, + 0, + false, + ); + + let session_dir = ensure_session_dir(&id)?; + let control_path = control_path_for_instance(&session_dir, &id); + let _ = fs::remove_file(&control_path); + let askpass_path = session_dir.join("askpass.sh"); + write_askpass_script(&askpass_path)?; + + self.set_status( + app, + &id, + DesktopSshPhase::MasterConnecting, + Some("Establishing SSH ControlMaster".to_string()), + None, + None, + None, + false, + 0, + false, + ); + + let mut master = spawn_master_process( + &parsed, + &control_path, + &askpass_path, + instance.auth.ssh_password.as_ref().and_then(|secret| { + if secret.enabled { + secret.value.as_deref() + } else { + None + } + }), + )?; + + if let Err(err) = wait_for_master_ready( + &parsed, + &control_path, + instance.connection_timeout_sec, + &mut master, + ) { + kill_child(&mut master); + return Err(err); + } + + self.set_status( + app, + &id, + DesktopSshPhase::RemoteProbe, + Some("Probing remote platform".to_string()), + None, + None, + None, + false, + 0, + false, + ); + + let remote_os = run_remote_command( + &parsed, + &control_path, + "uname -s", + instance.connection_timeout_sec, + )?; + + let remote_os = remote_os.trim().to_ascii_lowercase(); + if remote_os != "linux" && remote_os != "darwin" { + kill_child(&mut master); + return Err(anyhow!("Unsupported remote OS: {remote_os}")); + } + + let (remote_port, started_by_us) = + match self.ensure_remote_server(app, &instance, &parsed, &control_path) { + Ok(result) => result, + Err(err) => { + kill_child(&mut master); + return Err(err); + } + }; + + self.set_status( + app, + &id, + DesktopSshPhase::Forwarding, + Some("Setting up port forwards".to_string()), + None, + None, + Some(remote_port), + started_by_us, + 0, + false, + ); + + let bind_host = sanitize_bind_host(&instance.local_forward.bind_host); + let mut local_port = instance.local_forward.preferred_local_port.unwrap_or(0); + if local_port == 0 { + local_port = pick_unused_local_port()?; + } + if !is_local_port_available(&bind_host, local_port) { + local_port = pick_unused_local_port()?; + } + + let mut main_forward = + match spawn_main_forward(&parsed, &control_path, &bind_host, local_port, remote_port) { + Ok(child) => child, + Err(err) => { + kill_child(&mut master); + return Err(err); + } + }; + let mut main_forward_detached = false; + + std::thread::sleep(Duration::from_millis(250)); + if let Some(status) = main_forward.try_wait().ok().flatten() { + if status.success() { + main_forward_detached = true; + self.append_log_with_level( + &id, + "INFO", + "Main tunnel helper exited after ControlMaster handoff", + ); + } else { + let mut stderr = String::new(); + if let Some(mut stream) = main_forward.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + kill_child(&mut master); + return Err(anyhow!(format!( + "Failed to start main port forward (status: {status}): {}", + stderr.trim() + ))); + } + } + + let mut extra_forwards = Vec::new(); + let mut extra_errors = Vec::new(); + for forward in instance + .port_forwards + .iter() + .filter(|forward| forward.enabled) + { + match spawn_extra_forward(&parsed, &control_path, forward) { + Ok(()) => { + if matches!(forward.forward_type, DesktopSshPortForwardType::Local) { + if let Some(local_port) = forward.local_port { + std::thread::sleep(Duration::from_millis(100)); + if !is_local_tunnel_reachable(local_port) { + extra_errors.push(format!( + "{}: local listener 127.0.0.1:{} is not reachable", + forward.id, local_port + )); + } + } + } + } + Err(err) => extra_errors.push(format!("{}: {}", forward.id, err)), + } + } + + if let Err(err) = wait_local_forward_ready(local_port) { + kill_child(&mut main_forward); + for child in &mut extra_forwards { + kill_child(child); + } + kill_child(&mut master); + return Err(err); + } + + let local_url = format!("http://127.0.0.1:{local_port}"); + let label = build_display_label(&instance); + let _ = update_ssh_host_url(&id, &label, &local_url); + if instance.local_forward.preferred_local_port != Some(local_port) { + let _ = persist_local_port_for_instance(&id, local_port); + } + + self.sessions.lock().expect("ssh sessions mutex").insert( + id.clone(), + SshSession { + instance: instance.clone(), + parsed, + session_dir, + control_path, + local_port, + remote_port, + started_by_us, + master, + master_detached: false, + main_forward, + main_forward_detached, + extra_forwards, + }, + ); + + self.clear_retry_attempt(&id); + self.set_status( + app, + &id, + DesktopSshPhase::Ready, + if extra_errors.is_empty() { + Some("SSH instance is ready".to_string()) + } else { + Some(format!( + "SSH instance is ready with forward warnings: {}", + extra_errors.join("; ") + )) + }, + Some(local_url), + Some(local_port), + Some(remote_port), + started_by_us, + 0, + false, + ); + + self.spawn_monitor(app.clone(), id); + Ok(()) + } + + fn spawn_monitor(self: &Arc, app: AppHandle, id: String) { + self.cancel_monitor_task(&id); + let inner = Arc::clone(self); + let id_for_task = id.clone(); + let handle = tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + + let mut dropped_reason: Option = None; + let mut detached_notice: Option = None; + { + let mut sessions = inner.sessions.lock().expect("ssh sessions mutex"); + let Some(session) = sessions.get_mut(&id_for_task) else { + break; + }; + + let mut main_anchor_alive = false; + + if !session.main_forward_detached { + if let Some(status) = session.main_forward.try_wait().ok().flatten() { + if status.success() { + session.main_forward_detached = true; + detached_notice = Some( + "Main tunnel helper exited after ControlMaster handoff" + .to_string(), + ); + } else { + let mut stderr = String::new(); + if let Some(mut stream) = session.main_forward.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + dropped_reason = Some(if stderr.trim().is_empty() { + format!("Main SSH forward exited ({status})") + } else { + format!("Main SSH forward exited ({status}): {}", stderr.trim()) + }); + } + } else { + main_anchor_alive = true; + } + } + + if dropped_reason.is_none() { + if main_anchor_alive { + if !session.master_detached { + if let Some(status) = session.master.try_wait().ok().flatten() { + if status.success() + && is_control_master_alive( + &session.parsed, + &session.control_path, + ) + { + session.master_detached = true; + if detached_notice.is_none() { + detached_notice = Some( + "SSH ControlMaster transitioned to detached background mode" + .to_string(), + ); + } + } else { + detached_notice = Some( + "SSH ControlMaster exited while main tunnel is still active" + .to_string(), + ); + } + } + } else if !is_control_master_alive( + &session.parsed, + &session.control_path, + ) { + detached_notice = Some( + "SSH ControlMaster is not reachable; main tunnel remains active" + .to_string(), + ); + } + } else if session.master_detached { + if !is_control_master_alive(&session.parsed, &session.control_path) { + if is_local_tunnel_reachable(session.local_port) { + if detached_notice.is_none() { + detached_notice = Some( + "SSH ControlMaster check failed but local tunnel is still reachable" + .to_string(), + ); + } + } else { + dropped_reason = + Some("SSH ControlMaster is not reachable".to_string()); + } + } + } else if let Some(status) = session.master.try_wait().ok().flatten() { + if status.success() + && is_control_master_alive(&session.parsed, &session.control_path) + { + session.master_detached = true; + if detached_notice.is_none() { + detached_notice = Some( + "SSH ControlMaster transitioned to detached background mode" + .to_string(), + ); + } + } else { + let mut stderr = String::new(); + if let Some(mut stream) = session.master.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + dropped_reason = Some(if stderr.trim().is_empty() { + format!("SSH ControlMaster exited ({status})") + } else { + format!( + "SSH ControlMaster exited ({status}): {}", + stderr.trim() + ) + }); + } + } + } + } + + if let Some(message) = detached_notice { + inner.append_log_with_level(&id_for_task, "INFO", message); + } + + if dropped_reason.is_none() { + continue; + } + + let dropped_reason = + dropped_reason.unwrap_or_else(|| "SSH connection dropped".to_string()); + inner.append_log_with_level(&id_for_task, "WARN", dropped_reason.clone()); + + inner.disconnect_internal(&app, &id_for_task, false); + let attempt = inner.next_retry_attempt(&id_for_task); + + if attempt > DEFAULT_RECONNECT_MAX_ATTEMPTS { + inner.set_status( + &app, + &id_for_task, + DesktopSshPhase::Error, + Some(format!("{dropped_reason}. Retry limit reached")), + None, + None, + None, + false, + attempt, + true, + ); + break; + } + + inner.set_status( + &app, + &id_for_task, + DesktopSshPhase::Degraded, + Some(format!("{dropped_reason}. Reconnecting")), + None, + None, + None, + false, + attempt, + false, + ); + + let delay_ms = + (2u64.saturating_pow(attempt.saturating_sub(1))).saturating_mul(1000); + let jitter = (now_millis() % 700).saturating_add(100); + tokio::time::sleep(Duration::from_millis( + delay_ms.min(30_000).saturating_add(jitter), + )) + .await; + + if let Err(err) = inner.start_connect(app.clone(), id_for_task.clone()) { + inner.set_status( + &app, + &id_for_task, + DesktopSshPhase::Error, + Some(err), + None, + None, + None, + false, + attempt, + true, + ); + } + break; + } + + inner + .monitor_tasks + .lock() + .expect("ssh monitor task mutex") + .remove(&id_for_task); + }); + self.monitor_tasks + .lock() + .expect("ssh monitor task mutex") + .insert(id, handle); + } + + fn start_connect(self: &Arc, app: AppHandle, id: String) -> Result<(), String> { + let config = read_desktop_ssh_instances_from_disk(); + let Some(instance) = config.instances.into_iter().find(|item| item.id == id) else { + return Err("SSH instance not found".to_string()); + }; + + if self + .connect_tasks + .lock() + .expect("ssh connect task mutex") + .contains_key(&id) + { + self.append_log_with_level(&id, "INFO", "Connection already in progress"); + return Ok(()); + } + + if self.session_is_alive(&id) { + let snapshot = self.status_snapshot_for_instance(&id); + self.set_status( + &app, + &id, + DesktopSshPhase::Ready, + Some("SSH session already active".to_string()), + snapshot.local_url, + snapshot.local_port, + snapshot.remote_port, + snapshot.started_by_us, + snapshot.retry_attempt, + false, + ); + self.append_log_with_level( + &id, + "INFO", + "Connection already active; reusing existing SSH session", + ); + return Ok(()); + } + + let retry_attempt = self.current_retry_attempt(&id); + let connect_attempt = self.next_connect_attempt(&id); + self.append_attempt_separator(&id, connect_attempt, retry_attempt); + self.append_log(&id, "Starting SSH connection"); + self.disconnect_internal(&app, &id, false); + + let id_for_task = id.clone(); + let inner = Arc::clone(self); + let app_for_task = app.clone(); + let handle = tauri::async_runtime::spawn(async move { + let result = tauri::async_runtime::spawn_blocking({ + let inner = Arc::clone(&inner); + let app = app_for_task.clone(); + let instance = instance.clone(); + move || inner.connect_blocking(&app, instance) + }) + .await; + + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => { + inner.set_status( + &app_for_task, + &id_for_task, + DesktopSshPhase::Error, + Some(err.to_string()), + None, + None, + None, + false, + 0, + true, + ); + inner.disconnect_internal(&app_for_task, &id_for_task, false); + } + Err(err) => { + inner.set_status( + &app_for_task, + &id_for_task, + DesktopSshPhase::Error, + Some(format!("SSH task failed: {err}")), + None, + None, + None, + false, + 0, + true, + ); + inner.disconnect_internal(&app_for_task, &id_for_task, false); + } + } + + inner + .connect_tasks + .lock() + .expect("ssh connect task mutex") + .remove(&id_for_task); + }); + + self.connect_tasks + .lock() + .expect("ssh connect task mutex") + .insert(id, handle); + + Ok(()) + } + + fn statuses_with_defaults(&self) -> Vec { + let config = read_desktop_ssh_instances_from_disk(); + let statuses = self.statuses.lock().expect("ssh status mutex"); + let mut result = Vec::new(); + + for instance in config.instances { + result.push( + statuses + .get(&instance.id) + .cloned() + .unwrap_or_else(|| DesktopSshInstanceStatus::idle(instance.id)), + ); + } + + result.sort_by(|a, b| a.id.cmp(&b.id)); + result + } +} + +#[tauri::command] +pub fn desktop_ssh_logs( + state: State<'_, DesktopSshManagerState>, + id: String, + limit: Option, +) -> Result, String> { + let id = id.trim().to_string(); + if id.is_empty() || id == LOCAL_HOST_ID { + return Err("SSH instance id is required".to_string()); + } + let cap = limit.unwrap_or(200).min(MAX_LOG_LINES_PER_INSTANCE); + Ok(state.inner.logs_for_instance(&id, cap)) +} + +#[tauri::command] +pub fn desktop_ssh_logs_clear( + state: State<'_, DesktopSshManagerState>, + id: String, +) -> Result<(), String> { + let id = id.trim().to_string(); + if id.is_empty() || id == LOCAL_HOST_ID { + return Err("SSH instance id is required".to_string()); + } + state.inner.clear_logs_for_instance(&id); + Ok(()) +} + +impl DesktopSshManagerState { + pub fn shutdown_all(&self, app: &AppHandle) { + let ids: Vec = self + .inner + .sessions + .lock() + .expect("ssh sessions mutex") + .keys() + .cloned() + .collect(); + for id in ids { + self.inner.disconnect_internal(app, &id, false); + } + + let connect_ids: Vec = self + .inner + .connect_tasks + .lock() + .expect("ssh connect task mutex") + .keys() + .cloned() + .collect(); + for id in connect_ids { + self.inner.cancel_connect_task(&id); + } + + let monitor_ids: Vec = self + .inner + .monitor_tasks + .lock() + .expect("ssh monitor task mutex") + .keys() + .cloned() + .collect(); + for id in monitor_ids { + self.inner.cancel_monitor_task(&id); + } + } +} + +#[tauri::command] +pub fn desktop_ssh_instances_get() -> Result { + Ok(read_desktop_ssh_instances_from_disk()) +} + +#[tauri::command] +pub fn desktop_ssh_instances_set(config: DesktopSshInstancesConfig) -> Result<(), String> { + write_desktop_ssh_instances_to_path(&settings_file_path(), config) + .map(|_| ()) + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn desktop_ssh_import_hosts() -> Result, String> { + let mut candidates = Vec::new(); + + if let Some(home) = std::env::var_os("HOME") { + let user_config = PathBuf::from(home).join(".ssh").join("config"); + candidates.extend(parse_ssh_config_candidates(&user_config, "user")); + } + candidates.extend(parse_ssh_config_candidates( + Path::new("/etc/ssh/ssh_config"), + "global", + )); + + let mut seen = HashSet::new(); + candidates.retain(|item| seen.insert(item.host.clone())); + candidates.sort_by(|a, b| a.host.cmp(&b.host)); + Ok(candidates) +} + +#[tauri::command] +pub fn desktop_ssh_connect( + app: AppHandle, + state: State<'_, DesktopSshManagerState>, + id: String, +) -> Result<(), String> { + let id = id.trim().to_string(); + if id.is_empty() || id == LOCAL_HOST_ID { + return Err("SSH instance id is required".to_string()); + } + state.inner.start_connect(app, id) +} + +#[tauri::command] +pub fn desktop_ssh_disconnect( + app: AppHandle, + state: State<'_, DesktopSshManagerState>, + id: String, +) -> Result<(), String> { + let id = id.trim().to_string(); + if id.is_empty() || id == LOCAL_HOST_ID { + return Err("SSH instance id is required".to_string()); + } + state.inner.disconnect_internal(&app, &id, true); + Ok(()) +} + +#[tauri::command] +pub fn desktop_ssh_status( + state: State<'_, DesktopSshManagerState>, + id: Option, +) -> Result, String> { + if let Some(instance_id) = id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Ok(vec![state.inner.status_snapshot_for_instance(&instance_id)]); + } + + Ok(state.inner.statuses_with_defaults()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_instance(id: &str, command: &str) -> DesktopSshInstance { + DesktopSshInstance { + id: id.to_string(), + nickname: None, + ssh_command: command.to_string(), + ssh_parsed: None, + connection_timeout_sec: DEFAULT_CONNECTION_TIMEOUT_SEC, + remote_openchamber: DesktopSshRemoteOpenchamberConfig::default(), + local_forward: DesktopSshLocalForwardConfig::default(), + auth: DesktopSshAuthConfig::default(), + port_forwards: Vec::new(), + } + } + + #[test] + fn parse_ssh_command_accepts_supported_options() { + let parsed = parse_ssh_command( + "ssh -J jump.example.com -o StrictHostKeyChecking=accept-new user@example.com", + ) + .expect("parsed"); + assert_eq!(parsed.destination, "user@example.com"); + assert_eq!( + parsed.args, + vec![ + "-J".to_string(), + "jump.example.com".to_string(), + "-o".to_string(), + "StrictHostKeyChecking=accept-new".to_string(), + ] + ); + } + + #[test] + fn parse_ssh_command_rejects_disallowed_flags() { + let err = parse_ssh_command("ssh -M user@example.com") + .expect_err("should reject control master flag"); + assert!(err.to_string().contains("not allowed")); + } + + #[test] + fn parse_ssh_command_rejects_disallowed_controlpath_option() { + let err = parse_ssh_command("ssh -o ControlPath=/tmp/ssh.sock user@example.com") + .expect_err("should reject controlpath override"); + assert!(err.to_string().contains("not allowed")); + } + + #[test] + fn parse_ssh_command_keeps_ipv6_destination() { + let parsed = + parse_ssh_command("ssh user@[2001:db8::1]:2222").expect("parsed ipv6 destination"); + assert_eq!(parsed.destination, "user@[2001:db8::1]:2222"); + } + + #[test] + fn sync_desktop_hosts_removes_deleted_ssh_hosts() { + let mut root = json!({ + "desktopHosts": [ + {"id": "ssh-old", "label": "Old", "url": "http://127.0.0.1:1"}, + {"id": "http-1", "label": "HTTP", "url": "https://example.com"} + ], + "desktopDefaultHostId": "ssh-old" + }); + + let mut previous = HashSet::new(); + previous.insert("ssh-old".to_string()); + + let next = vec![sample_instance("ssh-new", "ssh user@example.com")]; + sync_desktop_hosts_for_ssh(&mut root, &previous, &next); + + let hosts = root + .get("desktopHosts") + .and_then(Value::as_array) + .expect("hosts array"); + assert_eq!(hosts.len(), 2); + assert!(hosts + .iter() + .any(|item| item.get("id") == Some(&Value::String("http-1".to_string())))); + assert!(hosts + .iter() + .any(|item| item.get("id") == Some(&Value::String("ssh-new".to_string())))); + assert_eq!( + root.get("desktopDefaultHostId").and_then(Value::as_str), + Some("local") + ); + } + + #[test] + fn parse_ssh_config_candidates_extracts_host_entries() { + let temp = + std::env::temp_dir().join(format!("openchamber-ssh-import-{}.txt", now_millis())); + fs::write( + &temp, + "\nHost prod\n HostName 10.0.0.1\nHost *.dev !skip\nHost *\n", + ) + .expect("write temp"); + + let candidates = parse_ssh_config_candidates(&temp, "user"); + let _ = fs::remove_file(&temp); + + assert!(candidates + .iter() + .any(|item| item.host == "prod" && !item.pattern)); + assert!(candidates + .iter() + .any(|item| item.host == "*.dev" && item.pattern)); + assert!(!candidates.iter().any(|item| item.host == "*")); + } + + #[test] + fn sanitize_instance_applies_defaults_and_parsed_command() { + let mut instance = sample_instance("ssh-1", "ssh user@example.com"); + instance.connection_timeout_sec = 0; + instance.local_forward.bind_host = "".to_string(); + + let normalized = sanitize_instance(instance).expect("sanitize instance"); + assert_eq!( + normalized.connection_timeout_sec, + DEFAULT_CONNECTION_TIMEOUT_SEC + ); + assert_eq!(normalized.local_forward.bind_host, "127.0.0.1"); + assert_eq!( + normalized.ssh_parsed.expect("parsed").destination, + "user@example.com" + ); + } + + #[test] + fn parse_probe_status_line_extracts_numeric_status() { + assert_eq!( + parse_probe_status_line(Some("INFO_STATUS=401"), "INFO_STATUS="), + Some(401) + ); + assert_eq!( + parse_probe_status_line(Some("INFO_STATUS=abc"), "INFO_STATUS="), + None + ); + assert_eq!( + parse_probe_status_line(Some("WRONG=200"), "INFO_STATUS="), + None + ); + } + + #[test] + fn liveness_status_accepts_success_and_auth_challenges() { + assert!(is_liveness_http_status(200)); + assert!(is_liveness_http_status(204)); + assert!(is_liveness_http_status(401)); + assert!(is_liveness_http_status(403)); + assert!(!is_liveness_http_status(500)); + assert!(!is_liveness_http_status(0)); + } +} diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 48134a13..b24c7914 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -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 ; }; +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> => { + const statuses = await desktopSshStatus().catch(() => []); + const next: Record = {}; + 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 => { + 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([]); const [defaultHostId, setDefaultHostId] = React.useState(null); const [statusById, setStatusById] = React.useState>({}); @@ -154,6 +253,23 @@ export function DesktopHostSwitcherDialog({ const [isProbing, setIsProbing] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const [switchingHostId, setSwitchingHostId] = React.useState(null); + const [sshHostIds, setSshHostIds] = React.useState>({}); + const [sshStatusesById, setSshStatusesById] = React.useState>({}); + 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(''); const [editingId, setEditingId] = React.useState(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 = {}; + 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({ )} + {tauriAvailable && ( +
+
+ Need SSH instances? Manage them in Settings. + +
+
+ )} + {!tauriAvailable && (
@@ -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}`} > - +
{displayLabel} + {isSsh && ( + + SSH + + )} {isActive && ( Current )} - {statusIcon(status?.status ?? null)} + {statusIcon(statusKind)} - {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` : ''}
@@ -491,7 +825,7 @@ export function DesktopHostSwitcherDialog({
- {!isLocal && ( + {!isLocal && !isSsh && ( + ) : ( + @@ -689,20 +1047,77 @@ export function DesktopHostSwitcherDialog({ ); + const sshSwitchDialog = ( + { + if (!nextOpen && switchingHostId) { + void cancelSshSwitch(); + return; + } + setSshSwitchModal((prev) => ({ + ...prev, + open: nextOpen, + ...(nextOpen ? {} : { hostId: null, error: null, detail: null, phase: 'idle' as const }), + })); + }} + > + + + + + Connecting to {sshSwitchModal.hostLabel || 'SSH instance'} + + + {sshSwitchModal.error + ? sshSwitchModal.error + : sshSwitchModal.detail || sshPhaseLabel(sshSwitchModal.phase)} + + + {sshSwitchModal.error ? ( +
+ + +
+ ) : null} +
+
+ ); + if (embedded) { return ( -
- {content} -
+ <> +
+ {content} +
+ {sshSwitchDialog} + ); } return ( - - - {content} - - + <> + + + {content} + + + {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(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 => { + 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 + { + if (!nextOpen && startupSshModal.connecting) { + return; + } + if (!nextOpen) { + setStartupSshModal((prev) => ({ + ...prev, + open: false, + connecting: false, + })); + return; + } + setStartupSshModal((prev) => ({ ...prev, open: true })); + }} + > + + + Default SSH instance unavailable + + {startupSshModal.connecting + ? `Connecting to ${startupSshModal.hostLabel || 'SSH instance'}...` + : startupSshModal.error || 'Failed to connect the default SSH instance.'} + + +
+ + +
+
+
); } diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx new file mode 100644 index 00000000..78ae57e4 --- /dev/null +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -0,0 +1,1617 @@ +import React from 'react'; +import { ButtonSmall } from '@/components/ui/button-small'; +import { Input } from '@/components/ui/input'; +import { NumberInput } from '@/components/ui/number-input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + RiAddLine, + RiArrowDownSLine, + RiArrowRightLine, + RiComputerLine, + RiExternalLinkLine, + RiFileCopyLine, + RiInformationLine, + RiPlug2Line, + RiRefreshLine, + RiServerLine, + RiShuffleLine, + RiTerminalWindowLine, + RiDeleteBinLine, + RiStopLine, +} from '@remixicon/react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; +import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import { + desktopSshLogsClear, + desktopSshLogs, + type DesktopSshInstance, + type DesktopSshPortForward, + type DesktopSshPortForwardType, +} from '@/lib/desktopSsh'; + +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 'config_resolved': + return 'Resolving configuration'; + case 'auth_check': + return 'Checking auth'; + case 'master_connecting': + return 'Establishing SSH'; + case 'remote_probe': + return 'Probing remote'; + case 'installing': + return 'Installing OpenChamber'; + case 'updating': + return 'Updating OpenChamber'; + case 'server_detecting': + return 'Detecting server'; + case 'server_starting': + return 'Starting server'; + case 'forwarding': + return 'Forwarding ports'; + case 'ready': + return 'Ready'; + case 'degraded': + return 'Reconnecting'; + case 'error': + return 'Error'; + default: + return 'Idle'; + } +}; + +const CONNECTING_PHASES = new Set([ + 'config_resolved', + 'auth_check', + 'master_connecting', + 'remote_probe', + 'installing', + 'updating', + 'server_detecting', + 'server_starting', + 'forwarding', +]); + +const isConnectingPhase = (phase?: string): boolean => { + return Boolean(phase && CONNECTING_PHASES.has(phase)); +}; + +const phaseDotClass = (phase?: string): string => { + if (phase === 'ready') { + return 'bg-[var(--status-success)] animate-pulse'; + } + if (phase === 'error') { + return 'bg-[var(--status-error)] animate-pulse'; + } + if (phase === 'degraded' || isConnectingPhase(phase)) { + return 'bg-[var(--status-warning)] animate-pulse'; + } + return 'bg-muted-foreground/40'; +}; + +const buildForwardLabel = (forward: DesktopSshPortForward): string => { + if (forward.type === 'dynamic') { + return `${forward.localHost || '127.0.0.1'}:${forward.localPort || 0}`; + } + if (forward.type === 'remote') { + return `${forward.remoteHost || '127.0.0.1'}:${forward.remotePort || 0} -> ${forward.localHost || '127.0.0.1'}:${forward.localPort || 0}`; + } + return `${forward.localHost || '127.0.0.1'}:${forward.localPort || 0} -> ${forward.remoteHost || '127.0.0.1'}:${forward.remotePort || 0}`; +}; + +const makeForward = (): DesktopSshPortForward => { + return { + id: `forward-${Date.now()}-${Math.random().toString(16).slice(2)}`, + enabled: true, + type: 'local', + localHost: '127.0.0.1', + localPort: randomPort(), + remoteHost: '127.0.0.1', + remotePort: 80, + }; +}; + +const suggestConcreteHost = (pattern: string): string => { + const value = pattern.trim().replace(/\*/g, 'host').replace(/\?/g, 'x'); + return value || 'user@host'; +}; + +const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label, hint }) => { + return ( + + {label} + + + + + +
{hint}
+
+
+
+ ); +}; + +const forwardTypeDescription = (type: DesktopSshPortForwardType): string => { + switch (type) { + case 'remote': + return 'Remote (-R): expose a port on the remote machine and send that traffic back to this laptop.'; + case 'dynamic': + return 'Dynamic (-D): create a local SOCKS5 proxy on this laptop (for apps that support SOCKS proxy settings).'; + default: + return 'Local (-L): open a port on this laptop and send it to a remote host:port over SSH (use this to access remote services locally).'; + } +}; + +const formatEndpoint = (host: string | undefined, port: number | undefined): string => { + const value = (host || '').trim(); + const normalizedHost = !value || value === '127.0.0.1' || value === '::1' ? 'localhost' : value; + return `${normalizedHost}:${port || 0}`; +}; + +const toBrowserHost = (host: string | undefined): string => { + const value = (host || '').trim(); + if (!value || value === '0.0.0.0' || value === '::') { + return '127.0.0.1'; + } + return value; +}; + +const formatLogLine = (line: string): string => { + const match = line.match(/^\[(\d{10,})\]\s*(?:\[([A-Z]+)\]\s*)?(.*)$/); + if (!match) { + return line; + } + + const millis = Number(match[1]); + const iso = Number.isFinite(millis) ? new Date(millis).toISOString() : match[1]; + const level = (match[2] || 'INFO').toUpperCase(); + const message = match[3] || ''; + return `[${iso}] [${level}] ${message}`; +}; + +type TauriShell = { + shell?: { + open?: (url: string) => Promise; + }; +}; + +const openExternalUrl = async (url: string): Promise => { + const target = url.trim(); + if (!target || typeof window === 'undefined') { + return false; + } + + const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; + if (tauri?.shell?.open) { + const openedWithTauri = await tauri.shell + .open(target) + .then(() => true) + .catch(() => false); + if (openedWithTauri) { + return true; + } + } + + try { + window.open(target, '_blank', 'noopener,noreferrer'); + return true; + } catch { + return false; + } +}; + +const navigateToUrl = (rawUrl: string): void => { + const target = rawUrl.trim(); + if (!target) { + return; + } + try { + window.location.assign(target); + } catch { + window.location.href = target; + } +}; + +const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => { + const trimmedCommand = instance.sshCommand.trim(); + const nickname = instance.nickname?.trim(); + const forwards = instance.portForwards.map((forward) => ({ + ...forward, + localHost: forward.localHost?.trim() || '127.0.0.1', + localPort: typeof forward.localPort === 'number' ? Math.max(1, Math.min(65535, Math.round(forward.localPort))) : undefined, + remoteHost: forward.remoteHost?.trim(), + remotePort: + typeof forward.remotePort === 'number' + ? Math.max(1, Math.min(65535, Math.round(forward.remotePort))) + : undefined, + })); + + return { + ...instance, + sshCommand: trimmedCommand, + ...(nickname ? { nickname } : { nickname: undefined }), + connectionTimeoutSec: Math.max(5, Math.min(240, Math.round(instance.connectionTimeoutSec || 60))), + localForward: { + ...instance.localForward, + bindHost: + instance.localForward.bindHost === 'localhost' || + instance.localForward.bindHost === '0.0.0.0' + ? instance.localForward.bindHost + : '127.0.0.1', + preferredLocalPort: + typeof instance.localForward.preferredLocalPort === 'number' + ? Math.max(1, Math.min(65535, Math.round(instance.localForward.preferredLocalPort))) + : undefined, + }, + remoteOpenchamber: { + ...instance.remoteOpenchamber, + preferredPort: + typeof instance.remoteOpenchamber.preferredPort === 'number' + ? Math.max(1, Math.min(65535, Math.round(instance.remoteOpenchamber.preferredPort))) + : undefined, + }, + portForwards: forwards, + }; +}; + +export const RemoteInstancesPage: React.FC = () => { + const instances = useDesktopSshStore((state) => state.instances); + const statusesById = useDesktopSshStore((state) => state.statusesById); + const importCandidates = useDesktopSshStore((state) => state.importCandidates); + const isImportsLoading = useDesktopSshStore((state) => state.isImportsLoading); + const isSaving = useDesktopSshStore((state) => state.isSaving); + const error = useDesktopSshStore((state) => state.error); + const load = useDesktopSshStore((state) => state.load); + const loadImports = useDesktopSshStore((state) => state.loadImports); + const refreshStatuses = useDesktopSshStore((state) => state.refreshStatuses); + const upsertInstance = useDesktopSshStore((state) => state.upsertInstance); + const createFromCommand = useDesktopSshStore((state) => state.createFromCommand); + const removeInstance = useDesktopSshStore((state) => state.removeInstance); + const connect = useDesktopSshStore((state) => state.connect); + const disconnect = useDesktopSshStore((state) => state.disconnect); + const retry = useDesktopSshStore((state) => state.retry); + + const selectedId = useUIStore((state) => state.settingsRemoteInstancesSelectedId); + const setSelectedId = useUIStore((state) => state.setSettingsRemoteInstancesSelectedId); + + const selectedInstance = React.useMemo(() => { + if (!selectedId) return null; + return instances.find((instance) => instance.id === selectedId) || null; + }, [instances, selectedId]); + + const [draft, setDraft] = React.useState(null); + const [logDialogOpen, setLogDialogOpen] = React.useState(false); + const [logDialogLoading, setLogDialogLoading] = React.useState(false); + const [logDialogError, setLogDialogError] = React.useState(null); + const [logDialogLines, setLogDialogLines] = React.useState([]); + const [patternHost, setPatternHost] = React.useState(null); + const [patternDestination, setPatternDestination] = React.useState(''); + const [patternCreating, setPatternCreating] = React.useState(false); + const [expandedForwards, setExpandedForwards] = React.useState>({}); + const [isPrimaryActionPending, setIsPrimaryActionPending] = React.useState(false); + const [isRetryPending, setIsRetryPending] = React.useState(false); + const [clockMs, setClockMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + void load(); + void loadImports(); + }, [load, loadImports]); + + React.useEffect(() => { + setDraft(selectedInstance); + }, [selectedInstance]); + + React.useEffect(() => { + if (!selectedId) { + return; + } + const interval = window.setInterval(() => { + void refreshStatuses(); + }, 2_000); + return () => { + window.clearInterval(interval); + }; + }, [refreshStatuses, selectedId]); + + React.useEffect(() => { + const interval = window.setInterval(() => { + setClockMs(Date.now()); + }, 1_000); + return () => { + window.clearInterval(interval); + }; + }, []); + + const status = selectedId ? statusesById[selectedId] : null; + const statusPhase = status?.phase; + const isReady = statusPhase === 'ready'; + const isReconnecting = statusPhase === 'degraded'; + const isConnecting = isConnectingPhase(statusPhase); + const isBusy = isConnecting || isReconnecting; + const canDisconnect = isReady || isBusy; + const statusAgeMs = status ? Math.max(0, clockMs - status.updatedAtMs) : 0; + const reconnectAppearsStuck = isReconnecting && statusAgeMs > 12_000; + + const hasChanges = React.useMemo(() => { + if (!draft || !selectedInstance) return false; + return JSON.stringify(draft) !== JSON.stringify(selectedInstance); + }, [draft, selectedInstance]); + + const updateDraft = React.useCallback((updater: (current: DesktopSshInstance) => DesktopSshInstance) => { + setDraft((current) => (current ? updater(current) : current)); + }, []); + + const handleSave = React.useCallback(async () => { + if (!draft) return; + const normalized = normalizeForSave(draft); + + if (!normalized.sshCommand.trim()) { + toast.error('SSH command is required'); + return; + } + + if (normalized.localForward.bindHost === '0.0.0.0') { + const allow = window.confirm( + 'Binding local forwards to 0.0.0.0 makes the forwarded port reachable from other devices on your network. Continue?', + ); + if (!allow) { + return; + } + } + + if ( + normalized.auth.sshPassword?.enabled && + normalized.auth.sshPassword.value?.trim() && + normalized.auth.sshPassword.store !== 'settings' + ) { + const store = window.confirm('Store SSH password in settings.json as plaintext?'); + normalized.auth.sshPassword.store = store ? 'settings' : 'never'; + if (!store) { + normalized.auth.sshPassword.value = undefined; + } + } + + if ( + normalized.auth.openchamberPassword?.enabled && + normalized.auth.openchamberPassword.value?.trim() && + normalized.auth.openchamberPassword.store !== 'settings' + ) { + const store = window.confirm('Store OpenChamber UI password in settings.json as plaintext?'); + normalized.auth.openchamberPassword.store = store ? 'settings' : 'never'; + if (!store) { + normalized.auth.openchamberPassword.value = undefined; + } + } + + try { + await upsertInstance(normalized); + toast.success('SSH instance saved'); + } catch (error) { + toast.error('Failed to save SSH instance', { + description: error instanceof Error ? error.message : String(error), + }); + } + }, [draft, upsertInstance]); + + const createImportedInstance = React.useCallback( + async (host: string, destination: string): Promise => { + const id = `ssh-${Date.now()}-${Math.random().toString(16).slice(2)}`; + try { + await createFromCommand(id, `ssh ${destination}`, host); + setSelectedId(id); + toast.success('SSH instance created'); + return true; + } catch (error) { + toast.error('Failed to create SSH instance', { + description: error instanceof Error ? error.message : String(error), + }); + return false; + } + }, + [createFromCommand, setSelectedId], + ); + + const closePatternDialog = React.useCallback(() => { + if (patternCreating) { + return; + } + setPatternHost(null); + setPatternDestination(''); + }, [patternCreating]); + + const handleImportCandidate = React.useCallback( + (host: string, pattern: boolean) => { + if (pattern) { + setPatternHost(host); + setPatternDestination(suggestConcreteHost(host)); + return; + } + void createImportedInstance(host, host); + }, + [createImportedInstance], + ); + + const handlePatternCreate = React.useCallback(async () => { + const host = patternHost; + const destination = patternDestination.trim(); + if (!host) { + return; + } + if (!destination) { + toast.error('Destination is required'); + return; + } + + setPatternCreating(true); + try { + const created = await createImportedInstance(host, destination); + if (created) { + setPatternHost(null); + setPatternDestination(''); + } + } finally { + setPatternCreating(false); + } + }, [createImportedInstance, patternDestination, patternHost]); + + const connectWithPortRecovery = React.useCallback(async () => { + if (!selectedInstance) return; + try { + await connect(selectedInstance.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 = { + ...selectedInstance, + localForward: { + ...selectedInstance.localForward, + preferredLocalPort: randomPort(), + }, + }; + + await upsertInstance(nextInstance); + await connect(nextInstance.id); + toast.success('Retried with a random local port'); + } + }, [connect, selectedInstance, upsertInstance]); + + const readLogsForInstance = React.useCallback(async (id: string) => { + const lines = await desktopSshLogs(id, 600); + return lines.map((line) => formatLogLine(line)); + }, []); + + const handleOpenLogs = React.useCallback(async () => { + if (!draft) return; + setLogDialogOpen(true); + setLogDialogLoading(true); + setLogDialogError(null); + try { + const lines = await readLogsForInstance(draft.id); + setLogDialogLines(lines); + } catch (error) { + setLogDialogLines([]); + setLogDialogError(error instanceof Error ? error.message : String(error)); + } finally { + setLogDialogLoading(false); + } + }, [draft, readLogsForInstance]); + + React.useEffect(() => { + if (!logDialogOpen || !draft) { + return; + } + + let disposed = false; + const run = async () => { + try { + const lines = await readLogsForInstance(draft.id); + if (!disposed) { + setLogDialogLines(lines); + setLogDialogError(null); + } + } catch (error) { + if (!disposed) { + setLogDialogError(error instanceof Error ? error.message : String(error)); + } + } + }; + + void run(); + const interval = window.setInterval(() => { + void run(); + }, 1_000); + + return () => { + disposed = true; + window.clearInterval(interval); + }; + }, [draft, logDialogOpen, readLogsForInstance]); + + const logLinesText = React.useMemo(() => logDialogLines.join('\n'), [logDialogLines]); + + const handleCopyAllLogs = React.useCallback(() => { + if (!logLinesText.trim()) { + toast.error('No logs to copy'); + return; + } + void copyTextToClipboard(logLinesText).then((result) => { + if (result.ok) { + toast.success('Logs copied'); + } + }); + }, [logLinesText]); + + const handleClearLogs = React.useCallback(async () => { + if (!draft) { + return; + } + try { + await desktopSshLogsClear(draft.id); + setLogDialogLines([]); + toast.success('Logs cleared'); + } catch (error) { + toast.error('Failed to clear logs', { + description: error instanceof Error ? error.message : String(error), + }); + } + }, [draft]); + + const handleOpenCurrentInstance = React.useCallback(async () => { + if (!status?.localUrl) { + toast.error('Instance URL is not available yet'); + return; + } + + const target = status.localUrl.trim(); + if (!target) { + toast.error('Instance URL is not available yet'); + return; + } + + navigateToUrl(target); + }, [status?.localUrl]); + + const handlePrimaryConnectionAction = React.useCallback(() => { + if (!draft) { + return; + } + + setIsPrimaryActionPending(true); + const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery(); + void operation + .catch((error) => { + const actionLabel = canDisconnect ? (isReady ? 'disconnect' : 'cancel connection') : 'connect'; + toast.error(`Failed to ${actionLabel}`, { + description: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + setIsPrimaryActionPending(false); + }); + }, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady]); + + const handleRetryAction = React.useCallback(() => { + if (!draft) { + return; + } + + if (isConnecting) { + return; + } + + setIsRetryPending(true); + const operation = isReconnecting + ? disconnect(draft.id).then(() => connectWithPortRecovery()) + : retry(draft.id); + + void operation + .catch((error) => { + toast.error('Retry failed', { + description: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + setIsRetryPending(false); + }); + }, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry]); + + const retryButtonLabel = isConnecting + ? 'Connecting...' + : isReconnecting + ? reconnectAppearsStuck + ? 'Reconnect now' + : 'Reconnecting...' + : 'Retry'; + + const canRetry = + !isPrimaryActionPending && + !isRetryPending && + (statusPhase === 'error' || statusPhase === 'idle' || !statusPhase || (isReconnecting && reconnectAppearsStuck)) && + !isConnecting; + + const primaryButtonLabel = isReady ? 'Disconnect' : canDisconnect ? 'Cancel' : 'Connect'; + + if (!draft) { + return ( + +
+
+

Remote Instances

+

Manage SSH-backed OpenChamber instances.

+
+
+

Select an instance from the sidebar or import one from SSH config.

+
+
+ +
+
+

Import from SSH config

+
+
+ {isImportsLoading ? ( +

Loading SSH hosts...

+ ) : importCandidates.length === 0 ? ( +

No SSH config hosts found.

+ ) : ( +
+ {importCandidates.map((candidate) => ( +
+
+
+ {candidate.host} + {candidate.pattern ? ' (pattern)' : ''} +
+
{candidate.source} config
+
+ void handleImportCandidate(candidate.host, candidate.pattern)} + > + Create + +
+ ))} +
+ )} +
+
+ + { + if (!open) { + closePatternDialog(); + } + }} + > + + + Create from wildcard pattern + + {patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'} + + +
{ + event.preventDefault(); + handlePatternCreate(); + }} + > + setPatternDestination(event.target.value)} + placeholder="user@host" + autoFocus + /> +
+ + Cancel + + + Create + +
+
+
+
+
+ ); + } + + const isManagedMode = draft.remoteOpenchamber.mode === 'managed'; + const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id; + + return ( + +
+

{instanceTitle}

+
+ + {phaseLabel(statusPhase)} + {status?.localUrl ? {status.localUrl} : null} + {reconnectAppearsStuck ? reconnect stale : null} +
+
+ +
+
+

Actions

+

Connect, inspect logs, and manage this instance.

+
+
+
+ + {canDisconnect ? : } + {primaryButtonLabel} + + + + {retryButtonLabel} + + { + void handleOpenLogs(); + }} + > + + Logs + + { + const ok = window.confirm('Remove this SSH instance?'); + if (!ok) return; + void removeInstance(draft.id) + .then(() => { + setSelectedId(null); + toast.success('SSH instance removed'); + }) + .catch((err) => { + toast.error('Failed to remove SSH instance', { + description: err instanceof Error ? err.message : String(err), + }); + }); + }} + > + + Remove + +
+ {status?.localUrl ? ( +
+ Current local URL: + {status.localUrl} +
+ ) : null} +
+
+ +
+
+

Instance

+

Core SSH settings.

+
+
+
+ SSH command + + updateDraft((current) => ({ + ...current, + sshCommand: event.target.value, + })) + } + placeholder="ssh -J jump user@host" + /> +
+
+ Nickname + + updateDraft((current) => ({ + ...current, + nickname: event.target.value, + })) + } + placeholder="Production Host" + /> +
+
+ Connection timeout (sec) + { + updateDraft((current) => ({ + ...current, + connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, + })); + }} + /> +
+
+
+ +
+
+

Remote server

+

How OpenChamber is discovered or started on the remote machine.

+
+
+
+
+ +
+ +
+ +
+
+ +
+ { + updateDraft((current) => ({ + ...current, + remoteOpenchamber: { + ...current.remoteOpenchamber, + preferredPort: Number.isFinite(next) && next > 0 ? next : undefined, + }, + })); + }} + onClear={() => { + updateDraft((current) => ({ + ...current, + remoteOpenchamber: { + ...current.remoteOpenchamber, + preferredPort: undefined, + }, + })); + }} + emptyLabel="Auto" + /> +
+ + {isManagedMode ? ( +
+
+ +
+ +
+ ) : null} + + {isManagedMode ? ( +
+
+ +
+
+ + updateDraft((current) => ({ + ...current, + remoteOpenchamber: { + ...current.remoteOpenchamber, + keepRunning: checked, + }, + })) + } + /> +
+
+ ) : null} +
+
+ +
+
+

Main tunnel

+

Primary local URL that points to the remote OpenChamber server.

+
+
+
+
+ +
+ +
+ +
+
+ +
+
+ { + updateDraft((current) => ({ + ...current, + localForward: { + ...current.localForward, + preferredLocalPort: Number.isFinite(next) && next > 0 ? next : undefined, + }, + })); + }} + onClear={() => { + updateDraft((current) => ({ + ...current, + localForward: { + ...current.localForward, + preferredLocalPort: undefined, + }, + })); + }} + emptyLabel="Auto" + /> + + updateDraft((current) => ({ + ...current, + localForward: { + ...current.localForward, + preferredLocalPort: randomPort(), + }, + })) + } + > + + +
+
+
+
+ +
+
+

Authentication

+

Optional credentials for SSH and remote UI.

+
+
+
+ SSH password (optional) + + updateDraft((current) => ({ + ...current, + auth: { + ...current.auth, + sshPassword: { + enabled: event.target.value.trim().length > 0, + value: event.target.value, + store: current.auth.sshPassword?.store || 'never', + }, + }, + })) + } + placeholder="Password or key passphrase" + /> +
+ +
+ OpenChamber UI password (optional) + + updateDraft((current) => ({ + ...current, + auth: { + ...current.auth, + openchamberPassword: { + enabled: event.target.value.trim().length > 0, + value: event.target.value, + store: current.auth.openchamberPassword?.store || 'never', + }, + }, + })) + } + placeholder="Protect remote UI with password" + /> +
+
+
+ +
+
+

Port Forwards

+

Optional extra SSH forwards in addition to the primary OpenChamber tunnel.

+
+
+ {draft.portForwards.length === 0 ? ( +

No extra forwards configured yet.

+ ) : null} + + {draft.portForwards.map((forward, index) => { + const updateForward = (updater: (forward: DesktopSshPortForward) => DesktopSshPortForward) => { + updateDraft((current) => ({ + ...current, + portForwards: current.portForwards.map((item, itemIndex) => + itemIndex === index ? updater(item) : item, + ), + })); + }; + + const localLabel = forward.type === 'remote' ? 'Local target' : 'Local listen'; + const localHint = forward.type === 'remote' + ? 'Local host and port on your machine that receives traffic from remote -R listener.' + : 'Local host and port where this forward listens on your machine.'; + const remoteLabel = forward.type === 'remote' ? 'Remote listen' : 'Remote target'; + const remoteHint = forward.type === 'remote' + ? 'Remote host and port where SSH creates the -R listener.' + : 'Remote host and port that receives traffic from local -L listener.'; + + const localEndpoint = formatEndpoint(forward.localHost || 'localhost', forward.localPort); + const remoteEndpoint = formatEndpoint(forward.remoteHost || 'localhost', forward.remotePort); + const canOpenLocalEndpoint = + forward.type === 'local' && typeof forward.localPort === 'number' && forward.localPort > 0; + const localEndpointUrl = canOpenLocalEndpoint + ? `http://${toBrowserHost(forward.localHost)}:${forward.localPort}` + : ''; + + const isForwardOpen = Boolean(expandedForwards[forward.id]); + + const typeLabel = forward.type === 'local' ? 'Local (-L)' : forward.type === 'remote' ? 'Remote (-R)' : 'Dynamic (-D)'; + + return ( + { + setExpandedForwards((current) => ({ + ...current, + [forward.id]: open, + })); + }} + className={`${index > 0 ? 'border-t border-[var(--surface-subtle)]' : ''} py-2`} + > +
+
+ + + {buildForwardLabel(forward)} + {typeLabel} + +
+
+ updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" /> + + updateDraft((current) => ({ + ...current, + portForwards: current.portForwards.filter((item) => item.id !== forward.id), + })) + } + > + + +
+
+ +
+

{forwardTypeDescription(forward.type)}

+
+
+ +
+ +
+ +
+
+ +
+
+ + updateForward((item) => ({ + ...item, + localHost: event.target.value, + })) + } + placeholder="127.0.0.1" + /> + : + { + updateForward((item) => ({ + ...item, + localPort: Number.isFinite(next) && next > 0 ? next : undefined, + })); + }} + onClear={() => { + updateForward((item) => ({ + ...item, + localPort: undefined, + })); + }} + emptyLabel="Auto" + /> +
+
+ + {forward.type !== 'dynamic' ? ( +
+
+ +
+
+ + updateForward((item) => ({ + ...item, + remoteHost: event.target.value, + })) + } + placeholder="127.0.0.1" + /> + : + { + updateForward((item) => ({ + ...item, + remotePort: Number.isFinite(next) && next > 0 ? next : undefined, + })); + }} + onClear={() => { + updateForward((item) => ({ + ...item, + remotePort: undefined, + })); + }} + emptyLabel="Auto" + /> +
+
+ ) : null} + +
+
+ {forward.type === 'dynamic' ? ( + <> + + {localEndpoint} + (local SOCKS5) + + ) : forward.type === 'remote' ? ( + <> + + {remoteEndpoint} + (remote) + + + {localEndpoint} + (local) + + ) : ( + <> + + {localEndpoint} + (local) + + + {remoteEndpoint} + (remote) + + )} +
+ + {canOpenLocalEndpoint ? ( + { + void openExternalUrl(localEndpointUrl).then((opened) => { + if (!opened) { + toast.error('Failed to open local endpoint'); + } + }); + }} + > + + Open local + + ) : null} +
+
+
+
+ ); + })} + + { + const nextForward = makeForward(); + updateDraft((current) => ({ + ...current, + portForwards: [...current.portForwards, nextForward], + })); + setExpandedForwards((current) => ({ + ...current, + [nextForward.id]: true, + })); + }} + > + + Add forward + +
+
+ +
+
+

Import from SSH config

+
+
+ {isImportsLoading ? ( +

Loading SSH hosts...

+ ) : importCandidates.length === 0 ? ( +

No SSH hosts available.

+ ) : ( +
+ {importCandidates.slice(0, 8).map((candidate, index) => ( +
0 ? 'border-t border-[var(--surface-subtle)]' : ''}`} + > +
+
+ {candidate.host} + {candidate.pattern ? ' (pattern)' : ''} +
+
{candidate.sshCommand}
+
+ void handleImportCandidate(candidate.host, candidate.pattern)} + > + Import + +
+ ))} +
+ )} +
+
+ +
+
+ void handleSave()} disabled={!hasChanges || isSaving}> + Save changes + + {status?.localUrl ? ( + <> + { + void copyTextToClipboard(status.localUrl || '').then((result) => { + if (result.ok) { + toast.success('Local URL copied'); + } + }); + }} + > + + Copy local URL + + { + void handleOpenCurrentInstance(); + }} + > + + Open + + + ) : null} + {error ?
{error}
: null} +
+
+ + + + + SSH Logs + + {draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || 'Selected instance'} + + +
+ + + Copy all + + void handleClearLogs()} disabled={logDialogLoading}> + + Clear + +
+ {logDialogLoading ? ( +
Loading logs...
+ ) : logDialogError ? ( +
{logDialogError}
+ ) : ( +
+              {logDialogLines.length > 0 ? logDialogLines.join('\n') : 'No SSH logs yet.'}
+            
+ )} +
+
+ + { + if (!open) { + closePatternDialog(); + } + }} + > + + + Create from wildcard pattern + + {patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'} + + +
{ + event.preventDefault(); + handlePatternCreate(); + }} + > + setPatternDestination(event.target.value)} + placeholder="user@host" + autoFocus + /> +
+ + Cancel + + + Create + +
+
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx new file mode 100644 index 00000000..ee492e07 --- /dev/null +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesSidebar.tsx @@ -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 = ({ 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 ( + +

Remote Instances

+
+ Total {instances.length} + +
+
+ } + > + {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 ( + { + 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), + }); + }); + }, + }, + ]} + /> + ); + })} + + ); +}; diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 0cc0620e..f49e3a92 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -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(() => { - 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(() => { diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 17fd6478..95394ae4 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -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 = ({ onClose, forceMobile const containerRef = React.useRef(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 = ({ onClose, forceMobile switch (slug) { case 'projects': return ; + case 'remote-instances': + return ; case 'agents': return ; case 'commands': @@ -407,6 +414,8 @@ export const SettingsView: React.FC = ({ onClose, forceMobile return ; case 'projects': return ; + case 'remote-instances': + return ; case 'agents': return ; case 'commands': diff --git a/packages/ui/src/lib/desktopSsh.ts b/packages/ui/src/lib/desktopSsh.ts new file mode 100644 index 00000000..f094a689 --- /dev/null +++ b/packages/ui/src/lib/desktopSsh.ts @@ -0,0 +1,454 @@ +import { isTauriShell } from '@/lib/desktop'; + +type TauriInvoke = (cmd: string, args?: Record) => Promise; + +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 => { + return typeof value === 'object' && value !== null; +}; + +const readString = (obj: Record, key: string): string | null => { + const value = obj[key]; + return typeof value === 'string' ? value : null; +}; + +const readNumber = (obj: Record, key: string): number | null => { + const value = obj[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +}; + +const readBoolean = (obj: Record, 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 => { + 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 => { + const invoke = getInvoke(); + if (!invoke) return; + await invoke('desktop_ssh_instances_set', { + config: { + instances: config.instances, + }, + }); +}; + +export const desktopSshImportHosts = async (): Promise => { + 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 => { + const invoke = getInvoke(); + if (!invoke) return; + await invoke('desktop_ssh_connect', { id }); +}; + +export const desktopSshDisconnect = async (id: string): Promise => { + const invoke = getInvoke(); + if (!invoke) return; + await invoke('desktop_ssh_disconnect', { id }); +}; + +export const desktopSshStatus = async (id?: string): Promise => { + 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 => { + 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 => { + const invoke = getInvoke(); + if (!invoke) return; + await invoke('desktop_ssh_logs_clear', { id }); +}; + +export const listenDesktopSshStatus = async ( + listener: (status: DesktopSshInstanceStatus) => void, +): Promise<() => Promise> => { + 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(); + }; +}; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index ba0f15f9..9eeb0992 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -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', diff --git a/packages/ui/src/stores/useDesktopSshStore.ts b/packages/ui/src/stores/useDesktopSshStore.ts new file mode 100644 index 00000000..20cde666 --- /dev/null +++ b/packages/ui/src/stores/useDesktopSshStore.ts @@ -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; + importCandidates: DesktopSshImportCandidate[]; + isLoading: boolean; + isSaving: boolean; + isImportsLoading: boolean; + initialized: boolean; + listenerReady: boolean; + error: string | null; + load: () => Promise; + loadImports: () => Promise; + refreshStatuses: () => Promise; + upsertInstance: (instance: DesktopSshInstance) => Promise; + createFromCommand: (id: string, sshCommand: string, nickname?: string) => Promise; + removeInstance: (id: string) => Promise; + setInstances: (instances: DesktopSshInstance[]) => Promise; + connect: (id: string) => Promise; + disconnect: (id: string) => Promise; + retry: (id: string) => Promise; + getStatus: (id: string) => DesktopSshInstanceStatus | null; + clearError: () => void; +}; + +const byUpdatedAt = (a: DesktopSshInstanceStatus, b: DesktopSshInstanceStatus) => { + return b.updatedAtMs - a.updatedAtMs; +}; + +export const useDesktopSshStore = create((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 = {}; + 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 = {}; + 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 }), +})); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index bf90df64..e78c6e5e 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( settingsPage: 'home', settingsHasOpenedOnce: false, settingsProjectsSelectedId: null, + settingsRemoteInstancesSelectedId: null, eventStreamStatus: 'idle', eventStreamHint: null, showReasoningTraces: true, @@ -847,6 +850,10 @@ export const useUIStore = create()( set({ settingsProjectsSelectedId: projectId }); }, + setSettingsRemoteInstancesSelectedId: (instanceId) => { + set({ settingsRemoteInstancesSelectedId: instanceId }); + }, + setEventStreamStatus: (status, hint) => { set({ eventStreamStatus: status, @@ -1343,6 +1350,7 @@ export const useUIStore = create()( settingsPage: state.settingsPage, settingsHasOpenedOnce: state.settingsHasOpenedOnce, settingsProjectsSelectedId: state.settingsProjectsSelectedId, + settingsRemoteInstancesSelectedId: state.settingsRemoteInstancesSelectedId, isSessionCreateDialogOpen: state.isSessionCreateDialogOpen, // Note: isSettingsDialogOpen intentionally NOT persisted showReasoningTraces: state.showReasoningTraces, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index f5b0466e..1cddce15 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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') ||