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