From d63dbfb19f5c78ec37e89bf4b6e37f2494bcd8a4 Mon Sep 17 00:00:00 2001 From: Janderson Date: Mon, 17 Aug 2026 16:16:06 -0400 Subject: [PATCH 1/8] feat(bundle): ship a native .rpm package for Fedora/RHEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppImage black-screens on some Wayland setups because its bundled WebKit runtime fails EGL initialization (EGL_BAD_PARAMETER) — see #73. A native package linking the distro's own webkit2gtk renders fine on the same machine. - add the rpm bundle target to tauri.conf.json - build, collect, and publish OmnySSH-x86_64.rpm in the release workflow - prefer the .rpm in install.sh on dnf-based systems, mirroring the .deb branch - list the .rpm in the README download table Verified on Fedora 44 (Wayland, Intel Arc): the AppImage reproduces the black screen; the .rpm built from this branch installs and renders correctly. --- .github/workflows/release.yml | 6 ++++-- README.md | 2 +- crates/omnyssh-gui/tauri.conf.json | 2 +- install.sh | 17 +++++++++++++++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41c0e41..f1260f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,7 +154,7 @@ jobs: # # macos-latest → aarch64-apple-darwin (native) → .dmg # macos-latest → x86_64-apple-darwin (cross) → .dmg - # ubuntu-22.04 → x86_64-unknown-linux-gnu → .deb + .AppImage + # ubuntu-22.04 → x86_64-unknown-linux-gnu → .deb + .AppImage + .rpm # windows-latest → x86_64-pc-windows-msvc → NSIS -setup.exe bundle-gui: name: Bundle GUI · ${{ matrix.target }} @@ -174,7 +174,7 @@ jobs: asset: OmnySSH-x86_64-apple-darwin - target: x86_64-unknown-linux-gnu runner: ubuntu-22.04 - bundles: deb,appimage + bundles: deb,appimage,rpm asset: OmnySSH-x86_64 - target: x86_64-pc-windows-msvc runner: windows-latest @@ -239,6 +239,7 @@ jobs: for f in "$out"/dmg/*.dmg; do [ -e "$f" ] && cp "$f" "${{ matrix.asset }}.dmg"; done for f in "$out"/appimage/*.AppImage; do [ -e "$f" ] && cp "$f" "${{ matrix.asset }}.AppImage"; done for f in "$out"/deb/*.deb; do [ -e "$f" ] && cp "$f" "${{ matrix.asset }}.deb"; done + for f in "$out"/rpm/*.rpm; do [ -e "$f" ] && cp "$f" "${{ matrix.asset }}.rpm"; done ls -lh "${{ matrix.asset }}".* - name: Collect bundles (Windows) @@ -334,5 +335,6 @@ jobs: artifacts/OmnySSH-x86_64-apple-darwin.dmg artifacts/OmnySSH-x86_64.AppImage artifacts/OmnySSH-x86_64.deb + artifacts/OmnySSH-x86_64.rpm artifacts/OmnySSH-x86_64-setup.exe artifacts/SHA256SUMS diff --git a/README.md b/README.md index 33a7370..6bb65c2 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The script detects your OS and architecture and installs the latest desktop buil |----------|------| | macOS Apple Silicon | `OmnySSH-aarch64-apple-darwin.dmg` | | macOS Intel | `OmnySSH-x86_64-apple-darwin.dmg` | -| Linux x86_64 | `OmnySSH-x86_64.AppImage` / `.deb` | +| Linux x86_64 | `OmnySSH-x86_64.AppImage` / `.deb` / `.rpm` | | Windows x86_64 | `OmnySSH-x86_64-setup.exe` | No account, no login screen, no telemetry. The app opens with an empty dashboard and reads your existing `~/.ssh/config` if you have one. diff --git a/crates/omnyssh-gui/tauri.conf.json b/crates/omnyssh-gui/tauri.conf.json index b4cc089..3db62da 100644 --- a/crates/omnyssh-gui/tauri.conf.json +++ b/crates/omnyssh-gui/tauri.conf.json @@ -30,7 +30,7 @@ }, "bundle": { "active": true, - "targets": ["dmg", "appimage", "deb", "nsis"], + "targets": ["dmg", "appimage", "deb", "nsis", "rpm"], "category": "Utility", "shortDescription": "Native GUI for the OmnySSH engine", "longDescription": "OmnySSH Desktop — SSH dashboard, terminal, and SFTP file manager.", diff --git a/install.sh b/install.sh index 3fe111e..bf9be74 100644 --- a/install.sh +++ b/install.sh @@ -386,8 +386,21 @@ install_gui_macos() { } install_gui_linux() { - # Prefer the .deb on Debian/Ubuntu — it integrates into the app menu and needs - # no FUSE. Fall back to the portable AppImage everywhere else. + # 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 + # AppImage ships (which black-screens on some Wayland setups). Fall back to + # the portable AppImage everywhere else. + if command -v rpm >/dev/null 2>&1 && command -v dnf >/dev/null 2>&1; then + download_release_asset "OmnySSH-${ARCH}.rpm" || return 1 + print_info "Installing the .rpm package..." + sudo dnf install -y "$ASSET_PATH" || { + print_error "Failed to install the .rpm package" + return 1 + } + print_success "OmnySSH installed. Launch it from your application menu." + return 0 + fi + if 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..." From d94d08e16e9e85e891dffe01a71863a7dc56f0be Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 13:20:49 +0400 Subject: [PATCH 2/8] fix(gui): declare the libraries the rpm links against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rpm bundler writes Requires: from bundle.linux.rpm.depends and nothing else — it builds the package with rpm-rs, so rpm's own find-requires never runs. Without the list dnf installs the package onto a system with no webview and the app dies at launch on a missing libwebkit2gtk. Sonames rather than package names: the package that provides them is called webkit2gtk4.1 on Fedora, libwebkit2gtk-4_1-0 on openSUSE and lib64webkit2gtk4.1_0 on Mageia, while the capability string is the same everywhere. The (64bit) suffix is correct for the x86_64 and aarch64 targets we build. --- crates/omnyssh-gui/tauri.conf.json | 9 +++++++ crates/omnyssh-gui/tests/startup_contract.rs | 25 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/omnyssh-gui/tauri.conf.json b/crates/omnyssh-gui/tauri.conf.json index 3db62da..637cbc5 100644 --- a/crates/omnyssh-gui/tauri.conf.json +++ b/crates/omnyssh-gui/tauri.conf.json @@ -31,6 +31,15 @@ "bundle": { "active": true, "targets": ["dmg", "appimage", "deb", "nsis", "rpm"], + "linux": { + "rpm": { + "depends": [ + "libwebkit2gtk-4.1.so.0()(64bit)", + "libjavascriptcoregtk-4.1.so.0()(64bit)", + "libgtk-3.so.0()(64bit)" + ] + } + }, "category": "Utility", "shortDescription": "Native GUI for the OmnySSH engine", "longDescription": "OmnySSH Desktop — SSH dashboard, terminal, and SFTP file manager.", diff --git a/crates/omnyssh-gui/tests/startup_contract.rs b/crates/omnyssh-gui/tests/startup_contract.rs index ce4acb6..4863fdb 100644 --- a/crates/omnyssh-gui/tests/startup_contract.rs +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -54,6 +54,31 @@ fn a_hidden_window_is_always_revealed() { ); } +/// The rpm bundler writes `Requires:` from this list and nothing else — it never scans +/// the binary — so an empty list ships a package that installs onto a system with no +/// webview and then dies at launch. Sonames, not package names: the package providing +/// them is called something different on every RPM distro. +#[test] +fn the_rpm_declares_the_libraries_it_links() { + let config: serde_json::Value = + serde_json::from_str(&read(Path::new(MANIFEST_DIR).join("tauri.conf.json"))) + .expect("tauri.conf.json is valid JSON"); + let depends = config["bundle"]["linux"]["rpm"]["depends"] + .as_array() + .expect("the rpm bundle declares its runtime dependencies"); + + for lib in [ + "libwebkit2gtk-4.1.so.0()(64bit)", + "libjavascriptcoregtk-4.1.so.0()(64bit)", + "libgtk-3.so.0()(64bit)", + ] { + assert!( + depends.iter().any(|d| d == lib), + "the rpm no longer requires {lib} — dnf would install a build that cannot start" + ); + } +} + /// `--bg` of the dark theme — the single source of truth for the app's backdrop. fn dark_background_token() -> String { let css = read(Path::new(MANIFEST_DIR).join("ui/src/app.css")); From b308efc5b52b51e6531015018fbc9dee276bcae2 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 13:26:24 +0400 Subject: [PATCH 3/8] fix(gui): retry with software rendering when the AppImage never paints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webview the AppImage bundles cannot always start hardware rendering on the host's graphics stack; when it fails the window opens on the dark base colour and stays blank, with the error going to a terminal nobody launched the app from. Twelve seconds after launch, an AppImage whose page never loaded now re-execs itself once with WEBKIT_DISABLE_DMABUF_RENDERER. The deadline is its own, well past the three-second reveal, so a slow-but-working start is never restarted. A marker in the child's environment bounds it to one retry and doubles as the opt-out. Guarded three ways so nothing that works today changes: Linux only at compile time, the running binary must live inside $APPDIR — which rules out deb and rpm installs, since an AppImage exports $APPDIR to everything it starts — and a page that loaded is left alone. The environment is set on the child through Command::env; env::set_var is unsound with threads running. --- crates/omnyssh-gui/src/main.rs | 159 ++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 63a6baf..fa0c218 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -37,6 +37,21 @@ const BINDINGS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui/src/lib/bin /// running process the user cannot see or reach. const REVEAL_FALLBACK: std::time::Duration = std::time::Duration::from_secs(3); +/// Set once the document is up. `is_visible()` stops answering that question the moment +/// 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")] +const RENDER_HEAL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(12); + +/// 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")] +const RETRY_MARKER: &str = "OMNYSSH_SOFTWARE_RENDER_RETRY"; + /// The single definition of the IPC surface. Shared by `main` (dev export + /// wiring) and the drift test so they can never disagree. fn specta_builder() -> Builder { @@ -108,6 +123,52 @@ fn export_bindings(path: impl AsRef) { .expect("failed to export TypeScript bindings"); } +/// 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))] +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, + } +} + +/// 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")] +fn exec_software_render_retry() -> std::io::Error { + use std::os::unix::process::CommandExt; + + // `args_os`: a non-UTF-8 argument must not panic the retry. + let mut args = std::env::args_os(); + let mut cmd = std::process::Command::new("/proc/self/exe"); + if let Some(arg0) = args.next() { + cmd.arg0(arg0); + } + cmd.args(args); + // On the child only: `env::set_var` is unsound with other threads running, and + // clearing the environment would throw away the AppDir's library paths. + cmd.env("WEBKIT_DISABLE_DMABUF_RENDERER", "1") + .env(RETRY_MARKER, "1"); + cmd.exec() +} + fn main() { let builder = specta_builder(); @@ -128,6 +189,7 @@ fn main() { // document — stylesheet included — is up. .on_page_load(|webview, payload| { if matches!(payload.event(), PageLoadEvent::Finished) { + PAGE_LOADED.store(true, std::sync::atomic::Ordering::Release); let window = webview.window(); // Reveal once: a later page load must not raise the window over // whatever the user is doing. @@ -154,6 +216,30 @@ 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")] + tauri::async_runtime::spawn(async { + tokio::time::sleep(RENDER_HEAL_DEADLINE).await; + let appdir = std::env::var_os("APPDIR").map(std::path::PathBuf::from); + 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(), + std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(), + ) { + eprintln!( + "OmnySSH: the interface never loaded — restarting once with software rendering" + ); + // `exec` returns only on failure; carry on with this process. + let err = exec_software_render_retry(); + eprintln!("OmnySSH: the restart failed ({err}) — continuing as is"); + } + }); + let (engine_tx, engine_rx) = tokio::sync::mpsc::channel::(256); // The additive PTY raw-byte tap (§3.6): the manager mirrors each session's // bytes into `raw_tx`; a forwarder demuxes them into per-tab channels. @@ -187,7 +273,8 @@ fn main() { #[cfg(test)] mod tests { - use super::{export_bindings, BINDINGS_PATH}; + 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). @@ -204,4 +291,74 @@ mod tests { "ui/src/lib/bindings.ts is out of date — regenerate with `cargo tauri dev`" ); } + + /// 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. + #[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 + )); + // The page arrived — there is nothing to heal. + assert!(!should_retry_software_rendering( + true, + Some(appdir), + Some(&exe), + 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 + )); + } } From f8ca3c42f955bcf9231af0e1c4316e1e1052379c Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 13:28:09 +0400 Subject: [PATCH 4/8] fix(install): fall back to the AppImage when the rpm cannot be installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rpm branch ended the install on any failure, and install.sh is served from main while releases are cut from tags — so between merging the rpm target and the next tag, every dnf host would download a 404 and exit without a GUI, where it used to get a working AppImage. A dnf host with no WebKitGTK 4.1 to satisfy the package, such as RHEL 9 and its rebuilds, hits the same dead end permanently. Both failures now warn and fall through to the AppImage. The deb and AppImage branches are unchanged. --- install.sh | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/install.sh b/install.sh index bf9be74..2863637 100644 --- a/install.sh +++ b/install.sh @@ -391,14 +391,17 @@ install_gui_linux() { # AppImage ships (which black-screens on some Wayland setups). Fall back to # the portable AppImage everywhere else. if command -v rpm >/dev/null 2>&1 && command -v dnf >/dev/null 2>&1; then - download_release_asset "OmnySSH-${ARCH}.rpm" || return 1 - print_info "Installing the .rpm package..." - sudo dnf install -y "$ASSET_PATH" || { - print_error "Failed to install the .rpm package" - return 1 - } - print_success "OmnySSH installed. Launch it from your application menu." - return 0 + if download_release_asset "OmnySSH-${ARCH}.rpm"; then + print_info "Installing the .rpm package..." + if sudo dnf install -y "$ASSET_PATH"; then + print_success "OmnySSH installed. Launch it from your application menu." + return 0 + fi + fi + # A release that predates the .rpm, or a dnf host that has no WebKitGTK 4.1 + # to satisfy it (RHEL 9 and its rebuilds), must not end the install here — + # the AppImage below still works. + print_warning "The .rpm is unavailable here — falling back to the AppImage" fi if command -v dpkg >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then From 0d80a3679acbace0fcd12505c9ad7a316a9ca18d Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 13:52:35 +0400 Subject: [PATCH 5/8] fix(gui): pin the guards the render retry depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the retry turned up four ways it could go wrong quietly: Nothing tied the retry to the page-load flag that disarms it. Delete that one store during a refactor of the reveal and every AppImage launch reads as a failed one, so the app would restart itself into software rendering every time — with the whole suite still green. The startup contract now asserts the store, and a const assert pins the heal deadline behind the reveal instead of a comment saying it should be. $APPDIR is the string the AppImage runtime exported while current_exe is the kernel's resolved path, so a symlinked TMPDIR left them uncomparable and silently disabled the retry; the AppDir is canonicalised first. WEBKIT_DISABLE_DMABUF_RENDERER=0 means DMABUF stays on, so presence alone was the wrong test for "the user already chose software rendering" — WebKit's own check is set and not "0". exec resets SIGPIPE to SIG_DFL in this process before execvp and does not restore it when execvp fails, which on a host with no /proc left the surviving app one closed SSH socket away from dying on a broken pipe. --- Cargo.lock | 1 + crates/omnyssh-gui/Cargo.toml | 5 ++++ crates/omnyssh-gui/src/main.rs | 25 +++++++++++++++++--- crates/omnyssh-gui/tests/startup_contract.rs | 13 ++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af0fc8a..f2724fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3219,6 +3219,7 @@ name = "omnyssh-gui" version = "1.1.1" dependencies = [ "chrono", + "libc", "omnyssh-core", "proptest", "serde", diff --git a/crates/omnyssh-gui/Cargo.toml b/crates/omnyssh-gui/Cargo.toml index 488b99a..4e2b2a5 100644 --- a/crates/omnyssh-gui/Cargo.toml +++ b/crates/omnyssh-gui/Cargo.toml @@ -44,6 +44,11 @@ tauri-plugin-opener = "2" # runtime; also asserts the wire form of DTOs in tests (e.g. HostDto never carries a password). serde_json = "1" +# Only to restore SIGPIPE after a failed `exec` in the AppImage render retry — `std` +# resets it as part of the child setup it runs in this process and never puts it back. +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + [dev-dependencies] # Property-based coverage of snippet param substitution (tech-gui.md §6.4, §7 Stage 2.2). proptest = "1" diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index fa0c218..9b62a07 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -47,6 +47,11 @@ static PAGE_LOADED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBoo #[cfg(target_os = "linux")] 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")] +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")] @@ -166,7 +171,14 @@ fn exec_software_render_retry() -> std::io::Error { // clearing the environment would throw away the AppDir's library paths. cmd.env("WEBKIT_DISABLE_DMABUF_RENDERER", "1") .env(RETRY_MARKER, "1"); - cmd.exec() + + let failure = cmd.exec(); + // `exec` sets up the child in *this* process before `execvp`, which includes resetting + // SIGPIPE to SIG_DFL, and it does not undo that when `execvp` fails. Left alone, the + // first SSH socket to close under a write would kill the app instead of erroring. + // SAFETY: restoring the disposition the Rust runtime installs at startup. + unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) }; + failure } fn main() { @@ -222,14 +234,21 @@ fn main() { #[cfg(target_os = "linux")] tauri::async_runtime::spawn(async { tokio::time::sleep(RENDER_HEAL_DEADLINE).await; - let appdir = std::env::var_os("APPDIR").map(std::path::PathBuf::from); + // `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(), - std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(), + // WebKit's own test: set, and not "0". + std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER") + .is_some_and(|value| value != "0"), ) { eprintln!( "OmnySSH: the interface never loaded — restarting once with software rendering" diff --git a/crates/omnyssh-gui/tests/startup_contract.rs b/crates/omnyssh-gui/tests/startup_contract.rs index 4863fdb..71c3f1e 100644 --- a/crates/omnyssh-gui/tests/startup_contract.rs +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -54,6 +54,19 @@ 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. +#[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 rpm bundler writes `Requires:` from this list and nothing else — it never scans /// the binary — so an empty list ships a package that installs onto a system with no /// webview and then dies at launch. Sonames, not package names: the package providing From 5fc0ceb62c97adb5ee50ee2512d6d13eec83feae Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 13:52:35 +0400 Subject: [PATCH 6/8] fix(install): keep an RPM host off the .deb branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rpm fall-through made the dpkg branch reachable on a dnf host for the first time, and Fedora carries dpkg and apt in its own repositories. Unpacking a .deb onto an RPM-managed filesystem is worse than the portable AppImage, so an RPM host now skips it. The warning also stops promising the AppImage will run — it still needs FUSE and a new enough glibc. --- install.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 2863637..67b4c13 100644 --- a/install.sh +++ b/install.sh @@ -390,7 +390,9 @@ install_gui_linux() { # needs no FUSE, and links the distro's own WebKit instead of the runtime the # AppImage ships (which black-screens on some Wayland setups). Fall back to # the portable AppImage everywhere else. + _rpm_host=0 if command -v rpm >/dev/null 2>&1 && command -v dnf >/dev/null 2>&1; then + _rpm_host=1 if download_release_asset "OmnySSH-${ARCH}.rpm"; then print_info "Installing the .rpm package..." if sudo dnf install -y "$ASSET_PATH"; then @@ -398,13 +400,16 @@ install_gui_linux() { return 0 fi fi - # A release that predates the .rpm, or a dnf host that has no WebKitGTK 4.1 - # to satisfy it (RHEL 9 and its rebuilds), must not end the install here — - # the AppImage below still works. - print_warning "The .rpm is unavailable here — falling back to the AppImage" + # A release that predates the .rpm, or a dnf host that has no WebKitGTK 4.1 to + # satisfy it (RHEL 9 and its rebuilds), must not end the install here. The + # AppImage is portable, but it needs FUSE and a new enough glibc — hence the + # hedge rather than a promise. + print_warning "The .rpm could not be installed — trying the AppImage instead" fi - if command -v dpkg >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then + # 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 || { From 5cdc157daa64ef1ab04fe304f3b87e0934b0e66e Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 13:52:35 +0400 Subject: [PATCH 7/8] docs(changelog): note the rpm package and the AppImage render retry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a4294..af8b1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Versions follow [Semantic Versioning](https://semver.org/). - 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. +- **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. + +### 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. --- From baf40f0a9faab69818a45d0ebc829e08be3bbdd4 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Tue, 18 Aug 2026 14:52:43 +0400 Subject: [PATCH 8/8] fix(install): drop the AppImage install the rpm replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fedora has no dpkg, so every GUI install there has gone through the AppImage branch, which writes $INSTALL_DIR/omnyssh and its own omnyssh.desktop. The package installs /usr/bin/OmnySSH and OmnySSH.desktop — different names, so nothing was overwritten and the user was left with two identical menu entries, one of them still launching the build they upgraded to escape. Only paths this script wrote are removed. --- install.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/install.sh b/install.sh index 67b4c13..d771762 100644 --- a/install.sh +++ b/install.sh @@ -396,6 +396,11 @@ 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 print_success "OmnySSH installed. Launch it from your application menu." return 0 fi