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
Reference in New Issue
Block a user