diff --git a/CHANGELOG.md b/CHANGELOG.md index 0618b34..3e3a006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Versions follow [Semantic Versioning](https://semver.org/). - **Watch a host by a port check instead of an SSH login.** Firewalls, switches and other appliances answer SSH but have no shell to read `top` or `free` from, so monitoring could only ever fail on them — and log in again every cycle to find that out. A host can now be set to **TCP port check**: OmnySSH opens a connection to the port and closes it, with no login and no commands. Its card shows reachable / unreachable in place of the metric tiles, rather than tiles for numbers nobody collected. Set it in the host form — `tcp` (the host's SSH port) or `tcp:PORT` in the terminal app, a dropdown in the desktop app. Existing hosts are untouched and stay on SSH monitoring. ICMP is not offered yet: unprivileged ping is unavailable on the Linux packaging most people use. ### Bug Fixes +- **The desktop app reopens at the size and position you left it.** Every launch reset the window to 1100x720 wherever the system chose to put it, so a window sized to your screen — or moved to a second monitor — had to be set up again each time. Size and position now persist. Visibility deliberately does not: the window still starts hidden and appears once the interface has painted, so restoring geometry never brings back the blank frame at launch. +- **macOS: the window buttons no longer sit across the edge of a collapsed sidebar.** The red/amber/green cluster is positioned by the system against the window, not by the app's layout, and it is wider than the collapsed sidebar was — so the sidebar's edge fell on the green button and left it over the content area while the other two stayed on the sidebar. The collapsed sidebar is now wide enough to hold the whole cluster on macOS. Windows and Linux draw their own title bar and keep the narrower one. - **Hosts split across `Include` files are imported.** A relative pattern — `Include conf.d/*.conf`, the form nearly every split-config guide prints — was looked for in whatever directory the app happened to be launched from, which for the desktop app started from Finder or the application menu is `/`. Nothing matched, so every host defined in `~/.ssh/conf.d` was missing and had to be added by hand. Those patterns now resolve against `~/.ssh`, the way `ssh` itself resolves them. Four things in the same code path were fixed alongside it: full glob patterns work (`?`, `[abc]`, a wildcard in a directory name, and more than one `*` in a file name), several pathnames on one `Include` line are all read instead of none, a quoted path keeps its spaces, and an `Include` written inside a `Host` block no longer swallows that host's remaining settings. An `Include` that matches nothing is written to the log rather than passing in silence. - **CPU no longer reads ~91–100 % on an idle server whose system language isn't English.** Monitoring reads the idle percentage from `top` and shows the rest as used. On a server set to a language with a comma decimal separator, `top` prints `99,1 id`, and the parser split that line on commas — reading the idle value as `1` and reporting 99 % used on a machine doing nothing. The same servers could show RAM and Disk as N/A, because `free` and `df` translate the labels being looked for, and the process list could come back empty. The monitoring commands now run in a fixed locale, and the CPU parsers understand a decimal comma on their own for hosts where that cannot be set. Process names keep the server's own character set. - **A host that answers SSH but not shell commands is no longer re-logged-in every 30 seconds.** Network appliances — firewalls, switches — authenticate fine but cannot run `top` or `free`, and a round of failed metric commands was treated as a dead connection. The retry delay was reset on every successful login, so it never grew past its first step: one login every 30 seconds, indefinitely, and as often as every 10 seconds in the desktop app, whose refresh timer also cut the retry delay short. The delay now escalates properly and is left alone by the refresh timer — the trade-off being that a manual refresh no longer cuts a retry delay short, so a host that has been unreachable for a while retries on its own schedule (at most five minutes). Separately, a device that ignores SSH keepalives was being disconnected after 30 seconds even while its commands still worked; connections are now held open, with liveness still bounded by the keepalive limit. diff --git a/Cargo.lock b/Cargo.lock index b85aa6d..f9991d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3233,6 +3233,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-store", "tauri-plugin-updater", + "tauri-plugin-window-state", "tauri-specta", "tokio", ] @@ -5475,6 +5476,21 @@ dependencies = [ "zip", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/crates/omnyssh-gui/Cargo.toml b/crates/omnyssh-gui/Cargo.toml index 4e2b2a5..25ef1f6 100644 --- a/crates/omnyssh-gui/Cargo.toml +++ b/crates/omnyssh-gui/Cargo.toml @@ -40,6 +40,9 @@ tauri-plugin-updater = "2" # Opens external URLs (the GitHub repo + Telegram channel from the support dialog) in # the user's default browser; the webview itself must never navigate away from the app. tauri-plugin-opener = "2" +# Restores the window's size and position between launches. Registered Rust-side only, +# so none of its commands are granted to the frontend. +tauri-plugin-window-state = "2" # `generate_context!` embeds the `plugins.updater` config and expands to serde_json at # runtime; also asserts the wire form of DTOs in tests (e.g. HostDto never carries a password). serde_json = "1" diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 9b62a07..8886ac6 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -27,6 +27,7 @@ use omnyssh_core::ssh::pty::PtyManager; use state::GuiState; use tauri::webview::PageLoadEvent; use tauri::Manager; +use tauri_plugin_window_state::StateFlags; use tauri_specta::{collect_commands, collect_events, Builder}; // Absolute at build time, so the export target is independent of the run CWD. @@ -57,6 +58,28 @@ const _: () = assert!(RENDER_HEAL_DEADLINE.as_secs() > REVEAL_FALLBACK.as_secs() #[cfg(target_os = "linux")] const RETRY_MARKER: &str = "OMNYSSH_SOFTWARE_RENDER_RETRY"; +/// What the window-state plugin is allowed to restore. Geometry only: it applies every +/// flag from `on_window_ready`, before the page exists, and only sizing and moving leave +/// a hidden window hidden. `VISIBLE` shows it outright, undoing the reveal that keeps the +/// blank webview off screen; `MAXIMIZED` reaches `ShowWindow(SW_MAXIMIZE)` on Windows, +/// which carries no visibility guard of its own; `FULLSCREEN` touches the same hidden +/// window; `DECORATIONS` re-derives the macOS style mask the overlay title bar needs. +/// +/// Dropping `MAXIMIZED` costs a maximised window its position: the plugin records a move +/// without checking for one, so the maximised origin becomes the saved position, and the +/// pre-maximise origin it keeps alongside is read back only for a window it also restores +/// maximised. Such a window reopens at its own size in the corner of the display. +const WINDOW_STATE_FLAGS: StateFlags = StateFlags::SIZE.union(StateFlags::POSITION); + +// The reveal is the only path to a visible window — the app has no tray icon — so the +// exclusions above are too load-bearing to live in prose alone. +const _: () = assert!(!WINDOW_STATE_FLAGS.intersects( + StateFlags::VISIBLE + .union(StateFlags::MAXIMIZED) + .union(StateFlags::FULLSCREEN) + .union(StateFlags::DECORATIONS) +)); + /// 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 { @@ -195,6 +218,13 @@ fn main() { .plugin(tauri_plugin_updater::Builder::new().build()) // Opens the support dialog's GitHub/Telegram links in the default browser. .plugin(tauri_plugin_opener::init()) + // Restores the window's size and position between launches; the flags keep it + // away from everything that would touch the window itself (WINDOW_STATE_FLAGS). + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags(WINDOW_STATE_FLAGS) + .build(), + ) .invoke_handler(builder.invoke_handler()) // The window is created hidden (tauri.conf.json `visible: false`) so the // launch never shows the webview's blank base colour; reveal it once the diff --git a/crates/omnyssh-gui/tests/startup_contract.rs b/crates/omnyssh-gui/tests/startup_contract.rs index 71c3f1e..22ba3f1 100644 --- a/crates/omnyssh-gui/tests/startup_contract.rs +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -67,6 +67,20 @@ fn a_loaded_page_is_recorded_for_the_render_retry() { ); } +/// Window geometry is restored by a plugin whose default flag set includes `VISIBLE`, +/// which it applies from `on_window_ready` — before the page exists. Fall back to those +/// defaults and every launch shows the window over a blank webview again, undoing the +/// reveal. `WINDOW_STATE_FLAGS` carries a compile-time guard of its own; this catches +/// the other half, a call site that stops using it. +#[test] +fn window_state_restores_geometry_but_never_visibility() { + assert!( + MAIN_RS.contains(".with_state_flags(WINDOW_STATE_FLAGS)"), + "the window-state plugin no longer takes its flags from WINDOW_STATE_FLAGS — \ + the default set includes VISIBLE and would reveal the window before it painted" + ); +} + /// 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 diff --git a/crates/omnyssh-gui/ui/src/app.css b/crates/omnyssh-gui/ui/src/app.css index 0052c50..b91c616 100644 --- a/crates/omnyssh-gui/ui/src/app.css +++ b/crates/omnyssh-gui/ui/src/app.css @@ -59,9 +59,15 @@ app.html pre-paint script so there is no layout shift. */ :root { --titlebar-h: 0px; + /* Collapsed sidebar width. The macOS traffic lights are anchored to the window, not + to the layout, and the cluster runs to x=69px — past a 3.5rem rail, whose right + border lands on the green button's left edge and leaves it over the content pane. + 5rem holds the whole cluster; the other platforms have no lights to clear. */ + --rail-w: 3.5rem; } :root[data-os='macos'] { --titlebar-h: 1.75rem; + --rail-w: 5rem; } html, diff --git a/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte b/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte index fdd795f..93fd113 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte @@ -27,7 +27,7 @@