diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c3676..4f761bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Versions follow [Semantic Versioning](https://semver.org/). --- +## 1.1.1 — 2026-07-28 + +### Bug Fixes +- **Windows: the desktop app no longer opens a console window next to itself.** The GUI was linked as a console application, so Windows gave it a terminal and kept it on screen for the whole session. Release builds now link as a GUI binary. The same fix would have made one-click SSH key setup flash a console of its own while `ssh-keygen` runs, so that is suppressed too. macOS and Linux were never affected. +- **No more white flash when the desktop app launches.** The window used to appear before the webview had painted anything, so the first frame was blank white before the dark (or light) interface took over. The window is now created hidden and revealed once the page is up, with a fallback that shows it anyway if the interface is slow to load. + +--- + ## 1.1.0 — 2026-07-24 ### Features diff --git a/Cargo.lock b/Cargo.lock index 4bb9597..af0fc8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "omnyssh" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "chrono", @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "omnyssh-core" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "async-trait", @@ -3216,7 +3216,7 @@ dependencies = [ [[package]] name = "omnyssh-gui" -version = "1.1.0" +version = "1.1.1" dependencies = [ "chrono", "omnyssh-core", diff --git a/Cargo.toml b/Cargo.toml index 65f7b70..aa44d6b 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.0" +version = "1.1.1" edition = "2021" license = "Apache-2.0" repository = "https://github.com/timhartmann7/omnyssh" diff --git a/crates/omnyssh-core/src/ssh/key_setup.rs b/crates/omnyssh-core/src/ssh/key_setup.rs index c1692d5..ae412d0 100644 --- a/crates/omnyssh-core/src/ssh/key_setup.rs +++ b/crates/omnyssh-core/src/ssh/key_setup.rs @@ -288,6 +288,11 @@ pub async fn generate_key_pair(host_name: &str, key_type: KeyType) -> Result<(Pa .arg("-C") .arg(format!("omnyssh-{}", host_name)); // Comment + // CREATE_NO_WINDOW: the GUI has no console to lend the child, so without this + // Windows opens one for it. Output is piped either way, so nothing is lost. + #[cfg(windows)] + keygen_cmd.creation_flags(0x0800_0000); + let keygen_output = keygen_cmd .output() .await diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 9b24c42..63a6baf 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -1,3 +1,7 @@ +// Release builds link as a Windows GUI binary, so launching the app never opens a +// console window alongside it. Debug builds keep the console for cargo output. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + //! OmnySSH Desktop entry point. Wires the tauri-specta IPC boundary (commands + //! events), regenerates the TypeScript bindings in dev, spawns the core-event //! bridge, and boots the window (tech-gui.md §3.3–§3.4). @@ -21,12 +25,18 @@ use commands::update::{check_update, install_update, load_update_config, save_up use omnyssh_core::event::{CoreEvent, SessionId}; use omnyssh_core::ssh::pty::PtyManager; use state::GuiState; +use tauri::webview::PageLoadEvent; use tauri::Manager; use tauri_specta::{collect_commands, collect_events, Builder}; // Absolute at build time, so the export target is independent of the run CWD. const BINDINGS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui/src/lib/bindings.ts"); +/// How long the hidden window may wait for the page before it is revealed anyway. +/// The app has no tray icon, so a frontend that never loads must not leave a +/// running process the user cannot see or reach. +const REVEAL_FALLBACK: std::time::Duration = std::time::Duration::from_secs(3); + /// 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 { @@ -113,9 +123,37 @@ fn main() { // Opens the support dialog's GitHub/Telegram links in the default browser. .plugin(tauri_plugin_opener::init()) .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 + // document — stylesheet included — is up. + .on_page_load(|webview, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) { + let window = webview.window(); + // Reveal once: a later page load must not raise the window over + // whatever the user is doing. + if !window.is_visible().unwrap_or(false) { + let _ = window.show(); + // A window shown after build does not become key on its own everywhere. + let _ = window.set_focus(); + } + } + }) .setup(move |app| { builder.mount_events(app); + let reveal = app.handle().clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(REVEAL_FALLBACK).await; + if let Some(window) = reveal.get_webview_window("main") { + // Only when the page never got there: on macOS showing an + // already-visible window raises it over whatever the user + // switched to meanwhile. + if !window.is_visible().unwrap_or(false) { + let _ = window.show(); + } + } + }); + 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. diff --git a/crates/omnyssh-gui/tauri.conf.json b/crates/omnyssh-gui/tauri.conf.json index 90ed6de..b4cc089 100644 --- a/crates/omnyssh-gui/tauri.conf.json +++ b/crates/omnyssh-gui/tauri.conf.json @@ -18,6 +18,7 @@ "minWidth": 880, "minHeight": 560, "backgroundColor": "#171717", + "visible": false, "theme": "Dark", "titleBarStyle": "Overlay", "hiddenTitle": true diff --git a/crates/omnyssh-gui/tests/startup_contract.rs b/crates/omnyssh-gui/tests/startup_contract.rs new file mode 100644 index 0000000..ce4acb6 --- /dev/null +++ b/crates/omnyssh-gui/tests/startup_contract.rs @@ -0,0 +1,78 @@ +//! Launch-time contract of the desktop app. These are link-time or native-window +//! settings that no runtime assertion can reach from a test binary (`cargo test` +//! always builds with `debug_assertions`), so they are guarded at their source. + +use std::path::Path; + +const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); +const MAIN_RS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/main.rs")); + +/// Without this attribute the binary links as a console app and Windows opens a +/// terminal next to the window for the whole session. +#[test] +fn release_builds_link_as_a_windows_gui_binary() { + assert!( + MAIN_RS.contains(r#"#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]"#), + "src/main.rs no longer declares the windows_subsystem attribute — release \ + builds would open a console window alongside the app on Windows" + ); +} + +/// The window is revealed only once the page is up, so the launch never shows the +/// webview's blank base colour. The background is what the fallback reveal paints +/// when the frontend is slow, so it has to match the app's own dark surface. +#[test] +fn the_window_starts_hidden_on_the_dark_background() { + 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 window = &config["app"]["windows"][0]; + + assert_eq!( + window["visible"], + serde_json::json!(false), + "the main window must be created hidden and revealed on page load" + ); + assert_eq!( + window["backgroundColor"].as_str().map(str::to_lowercase), + Some(dark_background_token()), + "the native window background drifted from the dark --bg token" + ); +} + +/// The other half of `visible: false`: drop either reveal and the app becomes a +/// running process with no window and no way to reach it. +#[test] +fn a_hidden_window_is_always_revealed() { + assert!( + MAIN_RS.contains(".on_page_load("), + "the page-load reveal is gone — the window would stay hidden until the fallback" + ); + assert!( + MAIN_RS.contains("REVEAL_FALLBACK"), + "the fallback reveal is gone — a frontend that never loads would leave no window" + ); +} + +/// `--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")); + let block = css + .split_once(":root[data-theme='dark']") + .expect("app.css declares a dark theme block") + .1; + block[..block.find('}').expect("the dark theme block is closed")] + .split_once("--bg:") + .expect("the dark theme block declares --bg") + .1 + .split(';') + .next() + .expect("--bg is terminated") + .trim() + .to_lowercase() +} + +fn read(path: impl AsRef) -> String { + let path = path.as_ref(); + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} diff --git a/crates/omnyssh/Cargo.toml b/crates/omnyssh/Cargo.toml index 7be9875..7dd0458 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.0" } +omnyssh-core = { path = "../omnyssh-core", version = "1.1.1" } # TUI ratatui = "0.29" diff --git a/doc/omny.1 b/doc/omny.1 index b941baf..e61e9ce 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.0" +.TH omny 1 "omny 1.1.1" .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.0 +v1.1.1