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/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. --- 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/README.md b/README.md index 5d0d3d3..78cede3 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 — hosts behind a bastion (`ProxyJump`) included. 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 63a6baf..9b62a07 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -37,6 +37,26 @@ 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); + +// 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")] +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 +128,59 @@ 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"); + + 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() { let builder = specta_builder(); @@ -128,6 +201,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 +228,37 @@ 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; + // `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") + .is_some_and(|value| value != "0"), + ) { + 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 +292,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 +310,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 + )); + } } diff --git a/crates/omnyssh-gui/tauri.conf.json b/crates/omnyssh-gui/tauri.conf.json index b4cc089..637cbc5 100644 --- a/crates/omnyssh-gui/tauri.conf.json +++ b/crates/omnyssh-gui/tauri.conf.json @@ -30,7 +30,16 @@ }, "bundle": { "active": true, - "targets": ["dmg", "appimage", "deb", "nsis"], + "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..71c3f1e 100644 --- a/crates/omnyssh-gui/tests/startup_contract.rs +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -54,6 +54,44 @@ 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 +/// 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")); diff --git a/install.sh b/install.sh index 3fe111e..d771762 100644 --- a/install.sh +++ b/install.sh @@ -386,9 +386,35 @@ 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. - if command -v dpkg >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then + # 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. + _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 + # 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 + 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 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 + + # 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 || {