From f66a831ff3cca6ef0a21bca2e189b937cfdba43f Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:16:21 +0400 Subject: [PATCH 1/9] test(poll): keep the reachability probe off the paused clock --- crates/omnyssh-core/tests/poll_backoff.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/omnyssh-core/tests/poll_backoff.rs b/crates/omnyssh-core/tests/poll_backoff.rs index cfac70f..9d6c99c 100644 --- a/crates/omnyssh-core/tests/poll_backoff.rs +++ b/crates/omnyssh-core/tests/poll_backoff.rs @@ -102,7 +102,10 @@ async fn an_unreachable_host_keeps_retrying_on_its_own_schedule() { assert!(retried, "the poller stopped retrying an unreachable host"); } -#[tokio::test(start_paused = true)] +// Real clock, unlike its neighbours: a paused clock auto-advances whenever the +// runtime idles, which it does while the loopback connect sits in the IO driver — +// so `TCP_PROBE_TIMEOUT` elapses in virtual time and the open port reads as dead. +#[tokio::test] async fn a_reachability_host_is_probed_without_an_ssh_session() { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let port = listener.local_addr().expect("local addr").port(); @@ -119,8 +122,10 @@ async fn a_reachability_host_is_probed_without_an_ssh_session() { let manager = PollManager::start(vec![host], tx, Duration::from_secs(30)); // Bounded: the poller never closes the channel, so an unbounded wait would - // hang instead of failing when the expected status stops arriving. - let reachable = tokio::time::timeout(Duration::from_secs(60), async { + // hang instead of failing when the expected status stops arriving. Real seconds + // now, and comfortably clear of `TCP_PROBE_TIMEOUT`: a budget equal to it would + // expire on the same dial it is meant to watch retry. + let reachable = tokio::time::timeout(Duration::from_secs(20), async { while let Some(event) = rx.recv().await { if let CoreEvent::HostStatusChanged(_, ConnectionStatus::Connected) = event { return true; @@ -135,6 +140,12 @@ async fn a_reachability_host_is_probed_without_an_ssh_session() { "the probe never reported the port as reachable" ); + // The probe has settled, so the clock can be paused for the rest: waiting out + // the cycles below in real seconds would be a two-minute test. A dial that now + // times out against the virtual clock only reports a status, which is what this + // half tolerates anyway. + tokio::time::pause(); + // And over the cycles that follow, it reports status only — metrics would be // invented. Elapsing without a metrics event is the pass. let stray_metrics = tokio::time::timeout(Duration::from_secs(120), async { From d25fe5bed168584e7566ada62718da935b0ad4bd Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:16:21 +0400 Subject: [PATCH 2/9] fix(tui): keep an adopted host's imported name through an edit --- crates/omnyssh/src/app/host.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/omnyssh/src/app/host.rs b/crates/omnyssh/src/app/host.rs index 0545897..327d159 100644 --- a/crates/omnyssh/src/app/host.rs +++ b/crates/omnyssh/src/app/host.rs @@ -486,10 +486,15 @@ impl App { // the saved copy would try to connect direct. host.proxy_jump = old_host.and_then(|h| h.proxy_jump.clone()); - // If editing a SSH config host, preserve original name for duplicate prevention - if was_ssh_config && old_name.is_some() { - host.original_ssh_host = old_name.clone(); - } + // An import is adopted under the name it was imported by; a copy + // already adopted keeps the one it carries. Dropping it brings the + // import back as a duplicate card, and takes with it the alias any + // other host's `ProxyJump` resolves through. + host.original_ssh_host = if was_ssh_config { + old_name.clone() + } else { + old_host.and_then(|h| h.original_ssh_host.clone()) + }; if let Some(slot) = state.hosts.get_mut(host_idx) { *slot = host.clone(); From 29f6f52c9ce654fffbd904217201c3399f382f41 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:16:21 +0400 Subject: [PATCH 3/9] fix(tui): stop a host edit from restarting every poller --- crates/omnyssh/src/app/host.rs | 115 ++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/crates/omnyssh/src/app/host.rs b/crates/omnyssh/src/app/host.rs index 327d159..4cb9459 100644 --- a/crates/omnyssh/src/app/host.rs +++ b/crates/omnyssh/src/app/host.rs @@ -23,6 +23,26 @@ pub const FORM_FIELD_LABELS: &[&str] = &[ "Monitoring (ssh | tcp | tcp:PORT)", ]; +/// Whether an edit changed anything a running poller reads. Everything else on +/// the form — tags, notes — is display-only, and restarting the pool for it drops +/// and re-authenticates every host's live monitoring session. +/// +/// `original_ssh_host` is in here because it is read for *other* hosts: a jump chain +/// resolves a `ProxyJump` alias against it, so changing it changes where a different +/// host connects. +fn poller_inputs_changed(before: &Host, after: &Host) -> bool { + before.name != after.name + || before.hostname != after.hostname + || before.user != after.user + || before.port != after.port + || before.identity_file != after.identity_file + || before.password != after.password + || before.proxy_jump != after.proxy_jump + || before.original_ssh_host != after.original_ssh_host + || before.monitoring != after.monitoring + || before.monitor_port != after.monitor_port +} + /// Renders a host's monitoring mode back into its form field. fn monitoring_value(host: &Host) -> String { match (host.monitoring, host.monitor_port) { @@ -473,10 +493,11 @@ impl App { Some(HostPopup::Edit { host_idx, form }) => match form.to_host(HostSource::Manual) { Ok(mut host) => { - let (old_name, _was_ssh_config) = { + let (old_name, _was_ssh_config, before) = { let mut state = self.state.write().await; let old_host = state.hosts.get(host_idx); let old_name = old_host.map(|h| h.name.clone()); + let before = old_host.cloned(); let was_ssh_config = old_host .map(|h| h.source == HostSource::SshConfig) .unwrap_or(false); @@ -499,7 +520,7 @@ impl App { if let Some(slot) = state.hosts.get_mut(host_idx) { *slot = host.clone(); } - (old_name, was_ssh_config) + (old_name, was_ssh_config, before) }; // If the host name changed, migrate all associated data @@ -527,7 +548,12 @@ impl App { self.save_manual_hosts().await; // The edit can change the address, the port or the monitoring - // mode, none of which a running poller picks up. + // mode, none of which a running poller picks up. The pool has no + // per-host restart, so this costs every other host its session — + // only worth it when a poller input actually moved. + if before + .as_ref() + .is_none_or(|b| poller_inputs_changed(b, &host)) { let state = self.state.read().await; if let Some(old) = self.poll_manager.take() { @@ -626,6 +652,89 @@ mod tests { } } + #[test] + fn only_a_connection_field_edit_restarts_the_pool() { + let base = Host { + name: String::from("web"), + hostname: String::from("10.0.0.1"), + ..Host::default() + }; + + // Display-only edits: the running poller would produce the same session. + let mut described = base.clone(); + described.tags = vec![String::from("prod")]; + described.notes = Some(String::from("the billing box")); + assert!(!poller_inputs_changed(&base, &described)); + + // The guard holds only because the form round-trips every compared field + // untouched. Masking the password field — a plausible hardening — would make + // each of these hosts look edited and quietly restore the old behaviour. + let populated = Host { + user: String::from("deploy"), + port: 2222, + identity_file: Some(String::from("~/.ssh/id_ed25519")), + password: Some(String::from("hunter2")), + tags: vec![String::from("prod"), String::from("web")], + notes: Some(String::from("the billing box")), + monitoring: MonitorMode::TcpPort, + monitor_port: Some(8443), + ..base.clone() + }; + let reopened = HostForm::from_host(&populated) + .to_host(HostSource::Manual) + .expect("the form round-trips a valid host"); + assert!( + !poller_inputs_changed(&populated, &reopened), + "opening and confirming the form unchanged must not restart the pool" + ); + + // Everything a poller dials, authenticates or watches with. + let moved = [ + Host { + name: String::from("web-1"), + ..base.clone() + }, + Host { + hostname: String::from("10.0.0.2"), + ..base.clone() + }, + Host { + user: String::from("deploy"), + ..base.clone() + }, + Host { + port: 2222, + ..base.clone() + }, + Host { + identity_file: Some(String::from("~/.ssh/id_ed25519")), + ..base.clone() + }, + Host { + password: Some(String::from("hunter2")), + ..base.clone() + }, + Host { + proxy_jump: Some(String::from("bastion")), + ..base.clone() + }, + Host { + monitoring: MonitorMode::TcpPort, + ..base.clone() + }, + Host { + monitor_port: Some(8443), + ..base.clone() + }, + ]; + for after in moved { + assert!( + poller_inputs_changed(&base, &after), + "a changed poller input went unnoticed" + ); + } + } + #[test] fn an_unusable_monitoring_value_is_rejected() { for text in ["tcp:0", "tcp:99999", "http", "tcp:"] { From 31759504a52487910ec6da22a587ae5768acd8e4 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:16:21 +0400 Subject: [PATCH 4/9] fix(gui): heal a blank window on native installs too --- crates/omnyssh-gui/src/main.rs | 140 +++++-------------- crates/omnyssh-gui/tests/startup_contract.rs | 41 +++++- 2 files changed, 72 insertions(+), 109 deletions(-) diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 8886ac6..996304a 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -42,20 +42,20 @@ const REVEAL_FALLBACK: std::time::Duration = std::time::Duration::from_secs(3); /// the fallback reveals the window, so the render check reads this instead. static PAGE_LOADED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -/// A page still missing this long into an AppImage launch is a broken graphics stack, -/// not a slow disk. Deliberately far past `REVEAL_FALLBACK`: that one only reveals a -/// window, this one restarts the process. -#[cfg(target_os = "linux")] +/// A page still missing this long into a launch is a broken graphics stack, not a slow +/// disk. Deliberately far past `REVEAL_FALLBACK`: that one only reveals a window, this +/// one restarts the process. +#[cfg(all(target_os = "linux", not(debug_assertions)))] const RENDER_HEAL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(12); // The heal must outlast the reveal, or it would judge a page that is merely still // loading. Prose in the doc comment above cannot fail a build; this can. -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(debug_assertions)))] const _: () = assert!(RENDER_HEAL_DEADLINE.as_secs() > REVEAL_FALLBACK.as_secs()); /// Marks the child of a software-rendering retry so it can only ever happen once. /// Exporting it by hand disables the retry — the intended escape hatch. -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(debug_assertions)))] const RETRY_MARKER: &str = "OMNYSSH_SOFTWARE_RENDER_RETRY"; /// What the window-state plugin is allowed to restore. Geometry only: it applies every @@ -152,34 +152,31 @@ fn export_bindings(path: impl AsRef) { } /// Whether a blank launch should be retried once with WebKit's software renderer. -/// `$APPDIR` alone would not do — an AppImage exports it to everything it starts, so a -/// deb install launched from an AppImage terminal would match. Requiring the running -/// binary to live inside the AppDir pins the retry to our own bundle. -// Compiled everywhere, called only on Linux, so the decision stays unit-testable. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +/// +/// Deliberately not restricted to the AppImage: the DMA-BUF failure this heals is a +/// WebKitGTK-and-driver problem, so a `.deb` or `.rpm` on the same machine blanks the +/// same way — and `install.sh` now prefers the `.rpm` on the distributions where it is +/// reported most. What keeps the retry safe is that it can only ever happen once +/// (`RETRY_MARKER`) and never overrides a renderer the user chose himself. +// Compiled everywhere and exercised by the tests, but called only from the release +// Linux heal, so every other build has to waive the unused warning. +#[cfg_attr(any(not(target_os = "linux"), debug_assertions), allow(dead_code))] fn should_retry_software_rendering( page_loaded: bool, - appdir: Option<&std::path::Path>, - current_exe: Option<&std::path::Path>, already_retried: bool, dmabuf_disabled: bool, ) -> bool { - if page_loaded || already_retried || dmabuf_disabled { - return false; - } - match (appdir, current_exe) { - (Some(appdir), Some(exe)) => exe.starts_with(appdir), - _ => false, - } + !page_loaded && !already_retried && !dmabuf_disabled } /// Re-exec ourselves with WebKit's software renderer. Returns only on failure. /// -/// `/proc/self/exe`, not `$APPIMAGE`: the AppImage runtime already put the AppDir's -/// `LD_LIBRARY_PATH` and `PATH` into this process and exec keeps them, while re-running -/// the AppImage would mount a second copy of the bundle whose first mount can no longer -/// be released — and the file may have been moved since launch (`install.sh` does that). -#[cfg(target_os = "linux")] +/// `/proc/self/exe`, not `$APPIMAGE`: inside an AppImage the runtime already put the +/// AppDir's `LD_LIBRARY_PATH` and `PATH` into this process and exec keeps them, while +/// re-running the AppImage would mount a second copy of the bundle whose first mount can +/// no longer be released — and the file may have been moved since launch (`install.sh` +/// does that). For a packaged install it is simply the installed binary. +#[cfg(all(target_os = "linux", not(debug_assertions)))] fn exec_software_render_retry() -> std::io::Error { use std::os::unix::process::CommandExt; @@ -258,23 +255,16 @@ fn main() { } }); - // Linux only: a window that never painted is usually the AppImage's bundled - // graphics stack losing to the host's. Retry once with software rendering — - // anything that rendered, or already retried, is left alone. - #[cfg(target_os = "linux")] + // Released Linux builds only: an interface that never loaded is usually + // WebKit's DMA-BUF renderer losing to the host's graphics driver. Retry once + // with software rendering — anything that loaded, or already retried, is left + // alone. Kept out of debug builds because `tauri dev` waits on a dev server, + // and a slow one starting is not a broken graphics stack. + #[cfg(all(target_os = "linux", not(debug_assertions)))] tauri::async_runtime::spawn(async { tokio::time::sleep(RENDER_HEAL_DEADLINE).await; - // `current_exe` is already the kernel's resolved path, so resolve the - // AppDir too — a symlinked $TMPDIR would otherwise make the two - // uncomparable and silently disable the retry. - let appdir = std::env::var_os("APPDIR") - .map(std::path::PathBuf::from) - .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir)); - let exe = std::env::current_exe().ok(); if should_retry_software_rendering( PAGE_LOADED.load(std::sync::atomic::Ordering::Acquire), - appdir.as_deref(), - exe.as_deref(), std::env::var_os(RETRY_MARKER).is_some(), // WebKit's own test: set, and not "0". std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER") @@ -323,7 +313,6 @@ fn main() { #[cfg(test)] mod tests { use super::{export_bindings, should_retry_software_rendering, BINDINGS_PATH}; - use std::path::Path; /// The committed bindings must match a fresh export — fails loudly on drift /// without mutating the tracked file (tech-gui.md §0.2 acceptance, §3.3). @@ -341,73 +330,18 @@ mod tests { ); } - /// The retry exists for one case only: no page, inside our own AppImage, not already - /// retried. Everything else must launch exactly as it does today. + /// The retry exists for one case only: no page, not already retried, no renderer the + /// user chose. Everything else must launch exactly as it does today. #[test] - fn the_software_render_retry_fires_once_inside_an_appimage() { - let appdir = Path::new("/tmp/.mount_OmnySSH"); - let exe = appdir.join("usr/bin/OmnySSH"); - - assert!(should_retry_software_rendering( - false, - Some(appdir), - Some(&exe), - false, - false - )); + fn the_software_render_retry_fires_once_on_a_blank_launch() { + assert!(should_retry_software_rendering(false, false, false)); // The page arrived — there is nothing to heal. - assert!(!should_retry_software_rendering( - true, - Some(appdir), - Some(&exe), - false, - false - )); + assert!(!should_retry_software_rendering(true, false, false)); // Already the retry, or the user asked for software rendering himself: a second // restart would only loop. - assert!(!should_retry_software_rendering( - false, - Some(appdir), - Some(&exe), - true, - false - )); - assert!(!should_retry_software_rendering( - false, - Some(appdir), - Some(&exe), - false, - true - )); - } - - /// A deb or rpm install must never restart itself. `$APPDIR` is inherited by anything - /// an AppImage launches, so where the binary actually lives is the check. - #[test] - fn the_software_render_retry_stays_out_of_native_installs() { - let appdir = Path::new("/tmp/.mount_OmnySSH"); - let installed = Path::new("/usr/bin/OmnySSH"); - - assert!(!should_retry_software_rendering( - false, - Some(appdir), - Some(installed), - false, - false - )); - assert!(!should_retry_software_rendering( - false, - None, - Some(installed), - false, - false - )); - assert!(!should_retry_software_rendering( - false, - Some(appdir), - None, - false, - false - )); + assert!(!should_retry_software_rendering(false, true, false)); + assert!(!should_retry_software_rendering(false, false, true)); + // Both guards at once, in case one is ever dropped. + assert!(!should_retry_software_rendering(false, true, true)); } } diff --git a/crates/omnyssh-gui/tests/startup_contract.rs b/crates/omnyssh-gui/tests/startup_contract.rs index 22ba3f1..c4bb5de 100644 --- a/crates/omnyssh-gui/tests/startup_contract.rs +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -54,16 +54,45 @@ fn a_hidden_window_is_always_revealed() { ); } -/// The reveal doubles as the signal that the page rendered, and the AppImage render -/// retry reads it. Drop this one store and every AppImage launch looks like a failed -/// one, so the app would restart itself into software rendering every single time — -/// with every other test still green. +/// The reveal doubles as the signal that the page loaded, and the render retry reads +/// it. Drop this one store and every Linux launch looks like a failed one, so the app +/// would restart itself into software rendering every single time — with every other +/// test still green. #[test] fn a_loaded_page_is_recorded_for_the_render_retry() { assert!( MAIN_RS.contains("PAGE_LOADED.store("), - "the page-load flag is no longer set — the AppImage would restart itself on \ - every launch" + "the page-load flag is no longer set — the app would restart itself on every \ + Linux launch" + ); +} + +/// A debug build waits on the dev server, so a slow one starting must never look like +/// a broken renderer and re-exec the app out from under a contributor. +#[test] +fn the_render_retry_is_a_release_only_behaviour() { + assert!( + MAIN_RS.contains(r#"#[cfg(all(target_os = "linux", not(debug_assertions)))]"#), + "the render retry is no longer gated to release builds — `tauri dev` would \ + restart itself whenever the dev server is slow" + ); +} + +/// The retry re-execs the app, so the one thing standing between it and an endless +/// restart loop is the child inheriting the marker the parent tested for. The two are +/// three lines apart and nothing else connects them: rename the string in `cmd.env` +/// alone and the loop is silent, unbounded, and only reproducible on Linux. +#[test] +fn the_render_retry_marks_the_child_it_starts() { + assert!( + MAIN_RS.contains(".env(RETRY_MARKER, \"1\")"), + "the render retry no longer marks its child with RETRY_MARKER — the restart \ + would repeat for as long as the page fails to load" + ); + assert!( + MAIN_RS.contains("std::env::var_os(RETRY_MARKER)"), + "the render retry no longer reads RETRY_MARKER — a marked child would restart \ + itself again" ); } From 0378f3b5b8a48e5af7b5f03cb84701cf033e8f9a Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:17:08 +0400 Subject: [PATCH 5/9] fix(install): attach the disk image this run downloaded --- install.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index d771762..2104b50 100644 --- a/install.sh +++ b/install.sh @@ -349,8 +349,17 @@ install_gui_macos() { download_release_asset "$GUI_ASSET" || return 1 DMG="$ASSET_PATH" print_info "Mounting disk image..." - MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" 2>/dev/null | grep -o '/Volumes/[^ ]*' | tail -n 1 || true) + # hdiutil prints tab-separated fields, and a volume name already taken is mounted + # as "OmnySSH 1" — so anything that stops at the first space picks up whatever the + # user left mounted and installs that instead. Take the last field of the last row. + _attach=$(hdiutil attach -nobrowse -readonly "$DMG" 2>/dev/null || true) + MOUNT=$(printf '%s\n' "$_attach" \ + | awk -F'\t' '$NF ~ /^\/Volumes\//{m=$NF} END{print m}') if [ -z "$MOUNT" ]; then + # The image may be attached even when its mount point cannot be read, and an + # orphan left in /Volumes is what makes the next run pick the wrong volume. + _dev=$(printf '%s\n' "$_attach" | awk '/^\/dev\//{d=$1} END{print d}') + [ -z "$_dev" ] || hdiutil detach "$_dev" >/dev/null 2>&1 || true print_error "Failed to mount $GUI_ASSET" return 1 fi From 2345435a1ac9435d4daf5f8462c0673438899876 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:17:08 +0400 Subject: [PATCH 6/9] fix(install): confirm the deb landed before reporting success --- install.sh | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/install.sh b/install.sh index 2104b50..3b8795c 100644 --- a/install.sh +++ b/install.sh @@ -424,14 +424,26 @@ install_gui_linux() { # Never on an RPM host, even one that happens to have dpkg: unpacking a .deb onto # an RPM-managed filesystem is worse than the portable AppImage. if [ "$_rpm_host" = 0 ] && command -v dpkg >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then - download_release_asset "OmnySSH-${ARCH}.deb" || return 1 - print_info "Installing the .deb package..." - sudo dpkg -i "$ASSET_PATH" || sudo apt-get install -f -y || { - print_error "Failed to install the .deb package" - return 1 - } - print_success "OmnySSH installed. Launch it from your application menu." - return 0 + if download_release_asset "OmnySSH-${ARCH}.deb"; then + print_info "Installing the .deb package..." + sudo dpkg -i "$ASSET_PATH" || sudo apt-get install -f -y || true + # `apt-get install -f` resolves an unsatisfiable dependency by removing the + # package dpkg just unpacked, and exits 0 having done it — so the exit status + # is no answer. Neither is `dpkg -s`, which succeeds for a package left + # half-configured, removed-but-not-purged, or still at its previous version. + # Ask the file what it is, then ask dpkg whether exactly that is installed. + _pkg=$(dpkg-deb -f "$ASSET_PATH" Package 2>/dev/null || true) + _pkg_version=$(dpkg-deb -f "$ASSET_PATH" Version 2>/dev/null || true) + _installed=$(dpkg-query -W -f='${db:Status-Status} ${Version}' \ + "$_pkg" 2>/dev/null || true) + if [ -n "$_pkg" ] && [ "$_installed" = "installed $_pkg_version" ]; then + print_success "OmnySSH installed. Launch it from your application menu." + return 0 + fi + fi + # Same hedge as the rpm branch above: a host too old for the package still + # gets a shot at the portable AppImage rather than a failed install. + print_warning "The .deb could not be installed — trying the AppImage instead" fi download_release_asset "$GUI_ASSET" || return 1 From cb7e8562d198a2ee7b99157b94a34edac7c70321 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:17:08 +0400 Subject: [PATCH 7/9] fix(install): drop the AppImage a native package replaces --- install.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 3b8795c..52a9476 100644 --- a/install.sh +++ b/install.sh @@ -394,6 +394,16 @@ install_gui_macos() { print_success "$APP_NAME installed. Launch it from Applications or Launchpad." } +# Removes an AppImage install this script left behind. A native package brings its +# own binary and menu entry under different names, so an earlier AppImage survives as +# a second, older launcher — and $INSTALL_DIR precedes /usr/bin on the default PATH, +# so `omnyssh` on the command line keeps running it. +drop_appimage_install() { + sudo rm -f "$INSTALL_DIR/omnyssh" 2>/dev/null || true + rm -f "$HOME/.local/share/applications/omnyssh.desktop" 2>/dev/null || true + rm -f "$HOME/.local/share/icons/omnyssh.png" 2>/dev/null || true +} + install_gui_linux() { # Prefer a native package where one exists — it integrates into the app menu, # needs no FUSE, and links the distro's own WebKit instead of the runtime the @@ -405,11 +415,7 @@ install_gui_linux() { if download_release_asset "OmnySSH-${ARCH}.rpm"; then print_info "Installing the .rpm package..." if sudo dnf install -y "$ASSET_PATH"; then - # The package brings its own binary and menu entry under different names - # than the AppImage this script installs, so an earlier AppImage would - # survive as a second launcher — the very build the user is escaping. - sudo rm -f "$INSTALL_DIR/omnyssh" || true - rm -f "$HOME/.local/share/applications/omnyssh.desktop" || true + drop_appimage_install print_success "OmnySSH installed. Launch it from your application menu." return 0 fi @@ -437,6 +443,7 @@ install_gui_linux() { _installed=$(dpkg-query -W -f='${db:Status-Status} ${Version}' \ "$_pkg" 2>/dev/null || true) if [ -n "$_pkg" ] && [ "$_installed" = "installed $_pkg_version" ]; then + drop_appimage_install print_success "OmnySSH installed. Launch it from your application menu." return 0 fi From 5300dac7f5b3d6acc3eca06c0f893c71037517d2 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:17:08 +0400 Subject: [PATCH 8/9] fix(install): install the TUI where no desktop build exists --- install.sh | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 52a9476..c89ef6e 100644 --- a/install.sh +++ b/install.sh @@ -326,6 +326,11 @@ download_release_asset() { # Install the GUI desktop app. Returns non-zero when unavailable for the platform. install_gui() { + # Set when no desktop build exists for this platform at all, as opposed to one + # that exists and failed to install. The caller falls back to the TUI on the + # first and reports failure on the second. + GUI_UNAVAILABLE=0 + # Public GUI asset names (match the release table): macOS keeps the full # target triple, Linux/Windows are x86_64-only so they use the short arch. case "$PLATFORM" in @@ -333,7 +338,8 @@ install_gui() { unknown-linux-gnu) GUI_ASSET="OmnySSH-${ARCH}.AppImage" ;; pc-windows-msvc) GUI_ASSET="OmnySSH-${ARCH}-setup.exe" ;; *) - print_warning "The desktop GUI is not available for $TARGET (TUI only)." + GUI_UNAVAILABLE=1 + print_warning "The desktop GUI is not available for $TARGET." return 1 ;; esac @@ -457,7 +463,9 @@ install_gui_linux() { APPIMAGE="$ASSET_PATH" TARGET_BIN="$INSTALL_DIR/omnyssh" print_info "Installing the AppImage to $TARGET_BIN..." - chmod +x "$APPIMAGE" + # Guarded rather than left to `set -e`: the caller runs this inside an `if`, which + # suspends errexit, and an AppImage that is not executable installs to silence. + chmod +x "$APPIMAGE" || { print_error "Failed to make the AppImage executable"; return 1; } if [ -w "$INSTALL_DIR" ]; then mv "$APPIMAGE" "$TARGET_BIN" || { print_error "Failed to install AppImage to $TARGET_BIN"; return 1; } else @@ -564,7 +572,18 @@ main() { case "$COMPONENTS" in gui) - install_gui + # aarch64 Linux and Termux run the TUI but have no desktop build, and a + # piped run defaults to the GUI — so the command the README prints would + # otherwise install nothing at all there. A GUI that exists and fails + # still fails: only the unsupported platform falls back. + if ! install_gui; then + [ "$GUI_UNAVAILABLE" = "1" ] || exit 1 + print_info "Installing the terminal app instead." + COMPONENTS="tui" + download_and_install + install_man_page + verify_installation + fi ;; both) download_and_install From 9790536ff3b2fff3c9bcc6fce42434f08ea07498 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Sat, 22 Aug 2026 18:17:21 +0400 Subject: [PATCH 9/9] chore(release): 1.1.2 --- CHANGELOG.md | 12 +++++++++--- Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/omnyssh/Cargo.toml | 2 +- doc/omny.1 | 4 ++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd771fc..ba2de95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Versions follow [Semantic Versioning](https://semver.org/). --- -## Unreleased +## 1.1.2 — 2026-08-22 ### Features - **Hosts imported from `~/.ssh/config` can be edited in the desktop app.** Imported hosts were shown read-only, so changing a port or a username for one meant deleting it from your SSH config and adding it again by hand — while the terminal app had allowed the edit all along. Editing one now saves your own copy, which OmnySSH uses from then on; `~/.ssh/config` is still never written to. The form says so before you save, since later changes you make to that file stop reaching the host once it has been adopted. The bastion and key path the app parsed are carried over even though the form cannot show them, so an adopted `ProxyJump` host keeps connecting through its jump server. Deleting is still offered for your own hosts only — there is nothing of an import to remove — and deleting a copy you adopted brings the imported version back, which the confirmation now tells you. @@ -27,13 +27,19 @@ Versions follow [Semantic Versioning](https://semver.org/). - Each hop's host key is checked against `known_hosts` under its own name, and each hop authenticates with the usual agent → identity file → default keys → password order. Every hop, including the tunnel opened on a bastion, is bound by the same ten-second budget, so a firewalled target cannot leave a host stuck on "connecting". - A bastion you renamed after importing it is still found by the alias other entries name it with. - One-click SSH key setup works for hosts behind a bastion: its verification steps now get the time the longer connection needs, and running out of time after password authentication has been disabled rolls the server back instead of reporting a clean failure. - - Editing an imported host in the TUI no longer drops its `ProxyJump`: the form has no field for it, so the saved copy used to lose the bastion. The GUI already preserved it. + - Editing an imported host in the TUI no longer drops its `ProxyJump`, nor the name it was imported under: the form has no field for either, so the saved copy used to lose the bastion, and a second edit lost the imported name — which brought the `~/.ssh/config` entry back as a duplicate card and left any other host's `ProxyJump` pointing at an alias nothing answered to. The GUI already preserved both. - **Windows: the console-window fix from 1.1.1 actually reaches you this time.** The fix was in that release's source, but the Windows installer published under the `v1.1.1` tag was an artifact left over from an earlier build — so the `.exe` it installed was the 1.1.0 one, still linked as a console application, and none of 1.1.1's Windows fixes (the console window, the white launch flash, the `ssh-keygen` console) were in it. The release now discards anything a previous build left behind, publishes only the bundle it just produced for the version being released, and refuses to publish at all if the desktop binary is not linked as a GUI application or if any installer is missing. - **The update banner links to the release instead of offering an install that cannot run.** Self-update has no endpoints configured yet, so the Install button reported "Self-update is not available yet" on every platform. It now opens the release page in your browser; Install returns when the desktop updater is wired up. -- **The Linux AppImage retries a window that never paints.** On some graphics setups the webview the AppImage carries cannot start hardware rendering, so the app opened as an empty dark frame and stayed that way, with the error going to a terminal nobody launched it from. If the interface still has not loaded twelve seconds in, the AppImage now restarts itself once with software rendering. A launch that renders normally is untouched, and the retry never happens for the `.deb`, `.rpm`, macOS or Windows builds. +- **A Linux desktop build that opens to an empty window restarts itself once.** On some graphics setups WebKit cannot start its hardware renderer, so the app opened as an empty dark frame and stayed that way, with the error going to a terminal nobody launched it from. If the interface still has not loaded twelve seconds in, OmnySSH now restarts itself once with software rendering. This covers the `.deb` and `.rpm` as well as the `.AppImage`: the failure is WebKit meeting a particular graphics driver rather than anything the AppImage does, and `install.sh` now prefers the `.rpm` on the distributions where it is reported most, so leaving the native packages out would have moved the affected people onto the one build with no answer for it. A launch that loads normally is untouched, a launch you started with `WEBKIT_DISABLE_DMABUF_RENDERER` set yourself is left alone, and the restart can happen only once. macOS and Windows are unaffected. +- **Editing a host no longer drops every other host's monitoring session.** Putting the monitoring mode on the host form meant the terminal app had to restart its polling pool after an edit, since a running poller does not pick up a changed address, port or mode. It restarted the whole pool for any edit at all, so fixing a typo in one host's notes reconnected and re-authenticated every server on the dashboard. The pool is now restarted only when the edit moved something a connection is actually made from — the name, address, user, port, key, password or monitoring mode. Editing tags or notes leaves every session where it was. +- **macOS: `install.sh` no longer installs an older copy of the app.** The installer read the mount point out of `hdiutil`'s output by stopping at the first space. When a volume called OmnySSH was already mounted — because you had opened the `.dmg` in Finder first, or because an earlier run was interrupted — macOS mounts the new one as `OmnySSH 1`, and the truncated path pointed back at the one already there. The script then copied that older app into `/Applications` and reported success. +- **Linux: a `.deb` that could not be installed is no longer reported as installed.** `apt-get install -f` resolves an unsatisfiable dependency by removing the package `dpkg` has just unpacked, and exits successfully having done so — which the installer read as a working install and announced as one, leaving you with the message and nothing else. It now asks `dpkg` what is actually installed, and falls back to the AppImage when the package did not survive, the way the `.rpm` path already did. +- **Linux: installing the `.deb` removes the AppImage it replaces.** The `.rpm` path already did this and the `.deb` path did not. The package installs to `/usr/bin` under its own name while an AppImage from an earlier run sits in `/usr/local/bin`, which comes first on the default `PATH` — so `omnyssh` on the command line kept starting the old build, and the application menu carried two identical entries. +- **ARM64 Linux and Termux install the terminal app instead of nothing.** No desktop build exists for those targets, and `curl … | sh` — the command the README prints — installs the desktop app by default. It warned and exited without installing anything, although the terminal app ships for exactly those targets. It now installs that instead. A desktop build that does exist and fails to install still fails rather than quietly falling back. ### Packaging - **A native `.rpm` for Fedora and other RPM distributions.** Releases now carry `OmnySSH-x86_64.rpm` alongside the `.AppImage` and `.deb`, and `install.sh` prefers it on `dnf`-based systems — it lands in your application menu and uninstalls with `dnf remove`, no FUSE involved. The package names the WebKitGTK 4.1, JavaScriptCore and GTK 3 libraries it links, so `dnf` resolves them from your own distribution instead of the app carrying a second copy. Distributions that ship no WebKitGTK 4.1 at all, such as RHEL 9 and its rebuilds, will refuse the package; `install.sh` then tries the AppImage there. +- **The Linux packages are named `omny-ssh`.** The bundler derives the package name from the product name, so the `.deb` and `.rpm` register as `omny-ssh` even though the files are called `OmnySSH-x86_64.deb` and `OmnySSH-x86_64.rpm`. Uninstall with `dnf remove omny-ssh` or `apt remove omny-ssh`. --- diff --git a/Cargo.lock b/Cargo.lock index f9991d7..06417d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "omnyssh" -version = "1.1.1" +version = "1.1.2" dependencies = [ "anyhow", "chrono", @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "omnyssh-core" -version = "1.1.1" +version = "1.1.2" dependencies = [ "anyhow", "async-trait", @@ -3218,7 +3218,7 @@ dependencies = [ [[package]] name = "omnyssh-gui" -version = "1.1.1" +version = "1.1.2" dependencies = [ "chrono", "libc", diff --git a/Cargo.toml b/Cargo.toml index aa44d6b..98f34ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["crates/omnyssh-core", "crates/omnyssh", "crates/omnyssh-gui"] default-members = ["crates/omnyssh-core", "crates/omnyssh"] [workspace.package] -version = "1.1.1" +version = "1.1.2" edition = "2021" license = "Apache-2.0" repository = "https://github.com/timhartmann7/omnyssh" diff --git a/crates/omnyssh/Cargo.toml b/crates/omnyssh/Cargo.toml index 7dd0458..db99f60 100644 --- a/crates/omnyssh/Cargo.toml +++ b/crates/omnyssh/Cargo.toml @@ -17,7 +17,7 @@ name = "omny" path = "src/main.rs" [dependencies] -omnyssh-core = { path = "../omnyssh-core", version = "1.1.1" } +omnyssh-core = { path = "../omnyssh-core", version = "1.1.2" } # TUI ratatui = "0.29" diff --git a/doc/omny.1 b/doc/omny.1 index e61e9ce..44d7b84 100644 --- a/doc/omny.1 +++ b/doc/omny.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH omny 1 "omny 1.1.1" +.TH omny 1 "omny 1.1.2" .SH NAME omny \- TUI SSH dashboard & server manager .SH SYNOPSIS @@ -28,4 +28,4 @@ Print help (see a summary with \*(Aq\-h\*(Aq) \fB\-V\fR, \fB\-\-version\fR Print version .SH VERSION -v1.1.1 +v1.1.2