Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions crates/omnyssh-gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
178 changes: 177 additions & 1 deletion crates/omnyssh-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<tauri::Wry> {
Expand Down Expand Up @@ -108,6 +128,59 @@ fn export_bindings(path: impl AsRef<std::path::Path>) {
.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();

Expand All @@ -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.
Expand All @@ -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::<CoreEvent>(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.
Expand Down Expand Up @@ -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).
Expand All @@ -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
));
}
}
11 changes: 10 additions & 1 deletion crates/omnyssh-gui/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
38 changes: 38 additions & 0 deletions crates/omnyssh-gui/tests/startup_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
32 changes: 29 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {
Expand Down
Loading