From f3c6745c0bff194a02182c54793231f14f89452d Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:44:06 +0200 Subject: [PATCH] feat(client): add single-instance/autostart/deep-link; move window-state to plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled code with first-party Tauri v2 plugins where a plugin can do the job, and add the genuine gaps: - single-instance: focus the running window on a second launch instead of opening a duplicate (two WS connections / tray icons). Registered first; built with the "deep-link" feature so owncord:// links reach the running app. - window-state: replace the hand-rolled save/restore plumbing with tauri-plugin-window-state. Keep only the one thing the plugin lacks — an off-screen re-center guard for windows restored onto a now-disconnected monitor (isRectOnScreen). - autostart: "Launch on Login" toggle in Advanced settings, reading/writing real OS state via tauri-plugin-autostart (not a stored preference). - deep-link: register the owncord:// scheme and route invite links into the register form. OwnCord invites are registration invites, so a link pre-fills and opens the form rather than completing a one-click join. Intentionally NOT replaced: push-to-talk (ptt.rs) stays hand-rolled — tauri-plugin-global-shortcut registers OS hotkeys that grab the key system-wide (RegisterHotKey / XGrabKey), which cannot express non-consuming press-and-hold PTT. Clipboard stays on the native Web API (no custom code). Verified: tsc, eslint, prettier, 3369 unit tests, cargo check, cargo clippy (client code clean; one pre-existing needless-borrow lint in commands.rs is flagged only by newer local clippy, untouched here). Co-Authored-By: Claude Opus 4.8 (1M context) --- Client/tauri-client/package-lock.json | 20 ++ Client/tauri-client/package.json | 2 + Client/tauri-client/src-tauri/Cargo.lock | 241 ++++++++++++++++-- Client/tauri-client/src-tauri/Cargo.toml | 9 + .../src-tauri/capabilities/default.json | 7 + Client/tauri-client/src-tauri/src/lib.rs | 37 ++- Client/tauri-client/src-tauri/tauri.conf.json | 5 + .../src/components/settings/AdvancedTab.ts | 56 ++++ Client/tauri-client/src/lib/deep-link.ts | 103 ++++++++ Client/tauri-client/src/lib/window-state.ts | 225 +++------------- Client/tauri-client/src/main.ts | 29 +++ Client/tauri-client/src/pages/ConnectPage.ts | 5 + .../src/pages/connect-page/LoginForm.ts | 16 ++ .../tauri-client/tests/unit/deep-link.test.ts | 36 +++ .../tests/unit/window-state-restore.test.ts | 146 +++-------- .../tests/unit/window-state.test.ts | 13 +- 16 files changed, 628 insertions(+), 322 deletions(-) create mode 100644 Client/tauri-client/src/lib/deep-link.ts create mode 100644 Client/tauri-client/tests/unit/deep-link.test.ts diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index bde9e838..64e7bd57 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@jitsi/rnnoise-wasm": "^0.2.1", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-autostart": "^2", + "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.7", @@ -3873,6 +3875,24 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", + "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", + "integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index b84ff78f..3b7a7805 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -61,6 +61,8 @@ "dependencies": { "@jitsi/rnnoise-wasm": "^0.2.1", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-autostart": "^2", + "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.7", diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index 1a4b1f54..f30bd028 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -266,6 +266,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -643,6 +654,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -807,6 +838,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -999,13 +1036,33 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -1016,8 +1073,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", - "windows-sys 0.60.2", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] @@ -1066,6 +1123,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1137,7 +1203,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1235,7 +1301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1826,6 +1892,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1996,7 +2068,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -2902,6 +2974,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2942,14 +3024,18 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", + "tauri-plugin-window-state", "tauri-typegen", "tokio", "tokio-rustls", @@ -3745,6 +3831,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3925,6 +4022,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3950,7 +4057,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4007,7 +4114,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4454,7 +4561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4745,7 +4852,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4795,7 +4902,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4867,6 +4974,41 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -4982,6 +5124,23 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-store" version = "2.4.2" @@ -5005,7 +5164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5031,6 +5190,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.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -5174,7 +5348,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5291,6 +5465,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -5569,7 +5752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5635,7 +5818,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6118,7 +6301,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6286,6 +6469,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6685,6 +6879,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6799,7 +7002,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 85f49942..7aae1b0a 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -52,6 +52,15 @@ env_logger = "0.11" keyring = "3" rfd = { version = "0.16", default-features = false } +# Desktop-only plugins (no mobile bundle target). single-instance carries the +# "deep-link" feature so an owncord:// link fired at a running app is forwarded +# to it instead of spawning a duplicate window. +[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } +tauri-plugin-window-state = "2" +tauri-plugin-autostart = "2" +tauri-plugin-deep-link = "2" + [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_UI_Input_KeyboardAndMouse"] } diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index 429c83a3..cbb601b9 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -20,6 +20,7 @@ "core:window:allow-outer-position", "core:window:allow-outer-size", "core:window:allow-available-monitors", + "core:window:allow-center", "notification:default", "notification:allow-notify", "notification:allow-request-permission", @@ -58,6 +59,12 @@ "opener:default", "dialog:default", "process:allow-restart", + "window-state:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled", + "deep-link:default", + "deep-link:allow-register", "fs:default", { "identifier": "fs:allow-write-file", diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 4821bd80..27c142bf 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -9,9 +9,28 @@ mod tray; mod update_commands; mod ws_proxy; +// Only used by the desktop-only single-instance closure below. +#[cfg(desktop)] +use tauri::Manager; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - match tauri::Builder::default() + let builder = tauri::Builder::default(); + + // The single-instance plugin MUST be registered first: a second launch is + // forwarded to the running instance (which we focus) instead of opening a + // duplicate window with a second WS connection / tray icon. With its + // "deep-link" feature this also routes an owncord:// link to the running app. + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })); + + let builder = builder .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_http::init()) @@ -19,7 +38,21 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_process::init()); + + // Desktop-only convenience plugins. window-state auto-saves/restores window + // geometry (off-screen correction lives in the frontend); autostart backs + // the "launch on login" toggle; deep-link registers the owncord:// scheme. + #[cfg(desktop)] + let builder = builder + .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None::>, + )) + .plugin(tauri_plugin_deep_link::init()); + + match builder .manage(ws_proxy::WsState::new()) .manage(livekit_proxy::LiveKitProxyState::new()) .manage(http_proxy::HttpProxyState::new()) diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index ef04986e..6ca1049f 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -77,6 +77,11 @@ "windows": { "installMode": "passive" } + }, + "deep-link": { + "desktop": { + "schemes": ["owncord"] + } } } } diff --git a/Client/tauri-client/src/components/settings/AdvancedTab.ts b/Client/tauri-client/src/components/settings/AdvancedTab.ts index b0383977..f35542de 100644 --- a/Client/tauri-client/src/components/settings/AdvancedTab.ts +++ b/Client/tauri-client/src/components/settings/AdvancedTab.ts @@ -56,6 +56,9 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement { section.appendChild(row); } + // Launch on login — OS-level state via the autostart plugin, not a stored pref. + section.appendChild(buildAutostartRow(signal)); + // ---- Separator ------------------------------------------------------------- const sep = createElement("div", { class: "settings-separator" }); @@ -217,6 +220,59 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement { // Helpers // --------------------------------------------------------------------------- +/** + * "Launch on login" toggle. Autostart is OS state, so the value is read from and + * written to `tauri-plugin-autostart` rather than the localStorage pref store. + * Outside Tauri (dev browser / tests) the plugin is unavailable and the row + * removes itself so it isn't misleading. + */ +function buildAutostartRow(signal: AbortSignal): HTMLDivElement { + const row = createElement("div", { class: "setting-row" }); + const info = createElement("div", {}); + const label = createElement("div", { class: "setting-label" }, "Launch on Login"); + const desc = createElement( + "div", + { class: "setting-desc" }, + "Start OwnCord automatically when you sign in to your computer", + ); + appendChildren(info, label, desc); + + // Starts off; corrected to the real OS state once the plugin answers. + let enabled = false; + const toggle = createToggle(false, { + signal, + onChange: (nowOn) => { + void (async () => { + try { + const { enable, disable } = await import("@tauri-apps/plugin-autostart"); + if (nowOn) await enable(); + else await disable(); + enabled = nowOn; + } catch (err) { + // The OS change didn't take — revert the visual state. + toggle.classList.toggle("on", enabled); + toggle.setAttribute("aria-checked", String(enabled)); + log.warn("Failed to change autostart", { error: String(err) }); + } + })(); + }, + }); + appendChildren(row, info, toggle); + + void (async () => { + try { + const { isEnabled } = await import("@tauri-apps/plugin-autostart"); + enabled = await isEnabled(); + toggle.classList.toggle("on", enabled); + toggle.setAttribute("aria-checked", String(enabled)); + } catch { + row.remove(); + } + })(); + + return row; +} + function buildCacheRow( label: string, desc: string, diff --git a/Client/tauri-client/src/lib/deep-link.ts b/Client/tauri-client/src/lib/deep-link.ts new file mode 100644 index 00000000..cfacc181 --- /dev/null +++ b/Client/tauri-client/src/lib/deep-link.ts @@ -0,0 +1,103 @@ +/** + * owncord:// deep links. + * + * OwnCord invites are *registration* invites (a code you supply when creating + * an account on a server), so a deep link can only pre-fill and open the + * register form — it cannot complete a join on its own. Accepted forms: + * + * owncord://invite/ + * owncord://invite/?host= + * owncord:// (bare code) + * + * Cold starts are handled via getCurrent(); while the app is already running, + * the single-instance plugin (built with the "deep-link" feature) forwards the + * link and onOpenUrl() fires. + */ + +import { createLogger } from "./logger"; + +const log = createLogger("deep-link"); + +const SCHEME = "owncord"; +const PREFIX = `${SCHEME}://`; + +export interface InviteLink { + readonly code: string; + readonly host?: string; +} + +/** + * Parse an owncord:// invite link. Returns null if the URL isn't an owncord:// + * link or carries no code. Pure — no side effects, safe to unit test. + */ +export function parseInviteLink(url: string): InviteLink | null { + if (!url.startsWith(PREFIX)) return null; + + let rest = url.slice(PREFIX.length); + let host: string | undefined; + + const queryStart = rest.indexOf("?"); + if (queryStart !== -1) { + const params = new URLSearchParams(rest.slice(queryStart + 1)); + const h = params.get("host")?.trim(); + if (h) host = h; + rest = rest.slice(0, queryStart); + } + + const segments = rest.replace(/\/+$/, "").split("/").filter(Boolean); + // `owncord://invite/` or bare `owncord://`. + const codeSegment = segments[0] === "invite" ? segments[1] : segments[0]; + if (!codeSegment) return null; + + let code: string; + try { + code = decodeURIComponent(codeSegment); + } catch { + code = codeSegment; + } + code = code.trim(); + if (!code) return null; + + return host ? { code, host } : { code }; +} + +/** + * Wire owncord:// deep links. No-op outside Tauri. `onInvite` is called once per + * recognized invite link, on both cold start and warm launches. + */ +export async function initDeepLinks( + onInvite: (code: string, host?: string) => void, +): Promise { + let plugin: typeof import("@tauri-apps/plugin-deep-link"); + try { + plugin = await import("@tauri-apps/plugin-deep-link"); + } catch { + return; // not running under Tauri (e.g. dev browser / tests) + } + + function dispatch(urls: readonly string[] | null): void { + for (const url of urls ?? []) { + const invite = parseInviteLink(url); + if (invite) { + log.info("Deep-link invite received", { hasHost: invite.host !== undefined }); + onInvite(invite.code, invite.host); + } else { + log.warn("Ignoring unrecognized deep link"); + } + } + } + + try { + // Runtime registration is idempotent and needed for dev + some Linux/Windows + // setups; the installer also registers the scheme from tauri.conf.json. + try { + await plugin.register(SCHEME); + } catch { + // Already registered, or not permitted on this platform — ignore. + } + dispatch(await plugin.getCurrent()); + await plugin.onOpenUrl((urls) => dispatch(urls)); + } catch (err) { + log.warn("Failed to initialize deep links", { error: String(err) }); + } +} diff --git a/Client/tauri-client/src/lib/window-state.ts b/Client/tauri-client/src/lib/window-state.ts index d0573f5d..735492cf 100644 --- a/Client/tauri-client/src/lib/window-state.ts +++ b/Client/tauri-client/src/lib/window-state.ts @@ -1,23 +1,26 @@ /** - * Window state persistence — saves/restores window position and size. - * Uses Tauri IPC commands backed by tauri-plugin-store. + * Window-state off-screen guard. + * + * Window geometry (size / position / maximized) is persisted and restored by + * `tauri-plugin-window-state` on the Rust side. That plugin does NOT validate + * the restored position against the current monitor layout, so this module adds + * the one thing it lacks: if the restored window landed off every connected + * monitor (e.g. it was last closed on a display that is now disconnected), + * re-center it so it can't become unreachable. */ import { createLogger } from "./logger"; const log = createLogger("window-state"); -export interface WindowState { +/** A window rectangle in physical pixels. */ +export interface WindowRect { readonly x: number; readonly y: number; readonly width: number; readonly height: number; - readonly maximized: boolean; } -const STORAGE_KEY = "windowState"; -const SAVE_DEBOUNCE_MS = 500; - /** Minimum horizontal overlap (physical px) required with some monitor. */ const MIN_VISIBLE_WIDTH = 100; /** Allow the title bar to sit slightly above a monitor's top edge. */ @@ -31,11 +34,11 @@ interface MonitorRect { } /** - * Check whether a saved window rect is reachable on one of the given - * monitors: enough horizontal overlap to grab, and the title bar row within - * the monitor's vertical range. All values are physical pixels. + * Check whether a window rect is reachable on one of the given monitors: + * enough horizontal overlap to grab, and the title bar row within the + * monitor's vertical range. All values are physical pixels. */ -export function isRectOnScreen(monitors: readonly MonitorRect[], rect: WindowState): boolean { +export function isRectOnScreen(monitors: readonly MonitorRect[], rect: WindowRect): boolean { return monitors.some((m) => { const overlapX = Math.min(rect.x + rect.width, m.position.x + m.size.width) - Math.max(rect.x, m.position.x); @@ -46,196 +49,44 @@ export function isRectOnScreen(monitors: readonly MonitorRect[], rect: WindowSta }); } -const invokePromise: Promise< - ((cmd: string, args?: Record) => Promise) | null -> = import("@tauri-apps/api/core") - .then((m) => m.invoke) - .catch((err) => { - log.warn("Tauri core API not available for window state", err); - return null; - }); - /** - * Save the current window state to the Tauri settings store. + * After `tauri-plugin-window-state` restores the window, re-center it if it + * landed off-screen. Fire-and-forget; a no-op outside Tauri. Fails open: if + * monitors can't be queried the plugin's placement is left untouched. */ -async function saveState(state: WindowState): Promise { - const invoke = await invokePromise; - if (!invoke) return; - try { - await invoke("save_settings", { key: STORAGE_KEY, value: state }); - } catch (err) { - log.error("Failed to save window state", { error: String(err) }); - } -} - -/** - * Load the previously saved window state. - */ -async function loadState(): Promise { - const invoke = await invokePromise; - if (!invoke) return null; - try { - const all = (await invoke("get_settings")) as Record; - const raw = all[STORAGE_KEY]; - if (raw && typeof raw === "object") { - const s = raw as Record; - if ( - typeof s.x === "number" && - typeof s.y === "number" && - typeof s.width === "number" && - typeof s.height === "number" && - typeof s.maximized === "boolean" && - Number.isFinite(s.x) && - Number.isFinite(s.y) && - Number.isFinite(s.width) && - Number.isFinite(s.height) && - s.width >= 1 && - s.height >= 1 - ) { - return { - x: s.x, - y: s.y, - width: s.width, - height: s.height, - maximized: s.maximized, - }; - } - } - return null; - } catch (err) { - log.error("Failed to load window state", { error: String(err) }); - return null; - } -} - -/** - * Check whether the saved rect is visible on a connected monitor. Fails open: - * if monitors cannot be queried, restore proceeds as before. - */ -async function isSavedRectVisible( - tauriWindow: typeof import("@tauri-apps/api/window"), - saved: WindowState, -): Promise { - let monitors: MonitorRect[]; - try { - monitors = await tauriWindow.availableMonitors(); - } catch (err) { - log.warn("Could not query monitors; restoring window state unchecked", { - error: String(err), - }); - return true; - } - if (monitors.length === 0) return true; - return isRectOnScreen(monitors, saved); -} - -/** - * Initialize window state persistence. - * Restores saved position/size on startup and listens for changes. - * Returns a cleanup function. - */ -export async function initWindowState(): Promise<() => void> { - let tauriWindow: typeof import("@tauri-apps/api/window") | undefined; +export async function initWindowState(): Promise { + let tauriWindow: typeof import("@tauri-apps/api/window"); try { tauriWindow = await import("@tauri-apps/api/window"); } catch { - return () => {}; + return; } const win = tauriWindow.getCurrentWindow(); - const cleanups: Array<() => void> = []; + try { + // A maximized window fills a monitor by definition — nothing to correct. + if (await win.isMaximized()) return; - // Restore saved state - const saved = await loadState(); - if (saved !== null) { + let monitors: MonitorRect[]; try { - if (saved.maximized) { - await win.maximize(); - log.info("Restored window state (maximized)"); - } else if (await isSavedRectVisible(tauriWindow, saved)) { - const pos = new tauriWindow.PhysicalPosition(saved.x, saved.y); - const size = new tauriWindow.PhysicalSize(saved.width, saved.height); - await win.setPosition(pos); - await win.setSize(size); - log.info("Restored window state", { - x: saved.x, - y: saved.y, - width: saved.width, - height: saved.height, - }); - } else { - // Saved rect is not reachable on any connected monitor (e.g. a - // disconnected display) — keep the default centered placement. - log.warn("Saved window position is off-screen; using default placement", { - x: saved.x, - y: saved.y, - width: saved.width, - height: saved.height, - }); - } + monitors = await tauriWindow.availableMonitors(); } catch (err) { - log.warn("Failed to restore window state", { error: String(err) }); + log.warn("Could not query monitors; leaving restored window as-is", { + error: String(err), + }); + return; } - } + if (monitors.length === 0) return; - // Debounced save on move/resize - let saveTimer: ReturnType | null = null; + const pos = await win.outerPosition(); + const size = await win.outerSize(); + const rect: WindowRect = { x: pos.x, y: pos.y, width: size.width, height: size.height }; - function debouncedSave(): void { - if (saveTimer !== null) { - clearTimeout(saveTimer); + if (!isRectOnScreen(monitors, rect)) { + log.warn("Restored window is off-screen; re-centering", rect); + await win.center(); } - saveTimer = setTimeout(() => { - void (async () => { - try { - // A minimized window reports placeholder coordinates (-32000 on - // Windows) — skip so the last real geometry survives a minimized - // exit. Checked separately so platforms without isMinimized still - // save normally. - let minimized = false; - try { - minimized = await win.isMinimized(); - } catch { - // Treat as not minimized - } - if (minimized) return; - const pos = await win.outerPosition(); - const size = await win.outerSize(); - const maximized = await win.isMaximized(); - await saveState({ - x: pos.x, - y: pos.y, - width: size.width, - height: size.height, - maximized, - }); - } catch { - // Window may have been closed during save - } - })(); - }, SAVE_DEBOUNCE_MS); + } catch (err) { + log.warn("Window-state off-screen check failed", { error: String(err) }); } - - try { - const unlistenMoved = await win.onMoved(() => debouncedSave()); - cleanups.push(unlistenMoved); - } catch { - // onMoved may not be available - } - - try { - const unlistenResized = await win.onResized(() => debouncedSave()); - cleanups.push(unlistenResized); - } catch { - // onResized may not be available - } - - return () => { - if (saveTimer !== null) { - clearTimeout(saveTimer); - } - for (const cleanup of cleanups) { - cleanup(); - } - }; } diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index b908e3d8..72ead6ad 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -26,6 +26,7 @@ import { createLogger } from "@lib/logger"; import { initLogPersistence, flushLogs } from "@lib/logPersistence"; import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials"; import { initWindowState } from "@lib/window-state"; +import { initDeepLinks } from "@lib/deep-link"; import { createCertMismatchModal, createCertFirstUseModal } from "@components/CertMismatchModal"; import { createProfileManager, createTauriBackend } from "@lib/profiles"; import type { CertTofuEvent } from "@lib/ws"; @@ -109,6 +110,10 @@ let lastConnectToken = ""; // mounted, cleared otherwise) — refreshes a server's status after its // certificate is trusted for the first time. let rerunConnectHealth: (() => void) | null = null; +// Set while the connect page is mounted so an owncord:// deep link can pre-fill +// its register form; the pending value covers links that arrive before it mounts. +let applyInviteToConnectPage: ((code: string, host?: string) => void) | null = null; +let pendingInviteLink: { code: string; host?: string } | null = null; // Shared guard so the first-use and mismatch cert modals never stack. let certModalActive = false; @@ -242,6 +247,7 @@ function renderPage(pageId: "connect" | "main"): void { appEl!.textContent = ""; // Only valid while the connect page is mounted (re-set in its render branch). rerunConnectHealth = null; + applyInviteToConnectPage = null; // Shared helper for post-auth WS connect + overlay flow function wirePostAuth( @@ -455,6 +461,14 @@ function renderPage(pageId: "connect" | "main"): void { // re-check the now-reachable server without a full page navigation. rerunConnectHealth = () => runHealthChecks(connectPage, getProfileList()); + // Route deep-link invites into this connect page. Apply any that arrived + // before it mounted. + applyInviteToConnectPage = (code, host) => connectPage.applyInviteLink(code, host); + if (pendingInviteLink !== null) { + connectPage.applyInviteLink(pendingInviteLink.code, pendingInviteLink.host); + pendingInviteLink = null; + } + // Load saved profiles and kick off health checks void (async () => { try { @@ -559,6 +573,21 @@ renderPage(router.getCurrentPage()); // Initialize window state persistence (fire-and-forget) void initWindowState(); +// Route owncord:// invite deep links into the register form. OwnCord invites +// are registration invites, so a link can only pre-fill + open the register +// form — it can't complete a join by itself. +function handleInviteDeepLink(code: string, host?: string): void { + pendingInviteLink = { code, host }; + router.navigate("connect"); + // If the connect page was already mounted, navigate() may not re-render it — + // apply directly. Otherwise the connect render branch consumes the pending link. + if (pendingInviteLink !== null && applyInviteToConnectPage !== null) { + applyInviteToConnectPage(code, host); + pendingInviteLink = null; + } +} +void initDeepLinks(handleInviteDeepLink); + // Initialize log persistence to disk (fire-and-forget) void initLogPersistence(); diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index 1d62b119..e5f885a2 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -58,6 +58,8 @@ export function createConnectPage( refreshProfiles(profiles: readonly SimpleProfile[]): void; /** Pre-select a server by host — fills the login form and loads saved credentials. */ selectServer(host: string, username?: string): void; + /** Pre-fill + switch to register mode from an owncord:// invite deep link. */ + applyInviteLink(code: string, host?: string): void; } { let container: Element | null = null; let root: HTMLDivElement; @@ -284,5 +286,8 @@ export function createConnectPage( } })(); }, + applyInviteLink(code: string, host?: string): void { + loginForm.applyInviteLink(code, host); + }, }; } diff --git a/Client/tauri-client/src/pages/connect-page/LoginForm.ts b/Client/tauri-client/src/pages/connect-page/LoginForm.ts index 1202f70e..ae599a16 100644 --- a/Client/tauri-client/src/pages/connect-page/LoginForm.ts +++ b/Client/tauri-client/src/pages/connect-page/LoginForm.ts @@ -58,6 +58,8 @@ export interface LoginFormApi { setHost(host: string): void; /** Set credentials (called for auto-fill from profile or credential store). */ setCredentials(username: string, password?: string): void; + /** Pre-fill + switch to register mode from an owncord:// invite deep link. */ + applyInviteLink(code: string, host?: string): void; /** Get host input value (for guard checks). */ getHost(): string; /** Focus the host input. */ @@ -684,6 +686,20 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi { } }, + /** + * Pre-fill the register form from an owncord:// invite deep link and switch + * to register mode. Host is optional — the link may carry only the code, in + * which case the user still needs to enter the server address. + */ + applyInviteLink(code: string, host?: string): void { + if (host) hostInput.value = host; + if (formMode !== "register") handleToggleMode(); + inviteInput.value = code; + // Focus the first field the user still has to fill in. + if (host) usernameInput.focus(); + else hostInput.focus(); + }, + getHost(): string { return hostInput?.value ?? ""; }, diff --git a/Client/tauri-client/tests/unit/deep-link.test.ts b/Client/tauri-client/tests/unit/deep-link.test.ts new file mode 100644 index 00000000..471b822e --- /dev/null +++ b/Client/tauri-client/tests/unit/deep-link.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { parseInviteLink } from "@lib/deep-link"; + +describe("parseInviteLink", () => { + it("parses owncord://invite/", () => { + expect(parseInviteLink("owncord://invite/ABC123")).toEqual({ code: "ABC123" }); + }); + + it("parses a bare owncord://", () => { + expect(parseInviteLink("owncord://XYZ")).toEqual({ code: "XYZ" }); + }); + + it("extracts the host from the query string", () => { + expect(parseInviteLink("owncord://invite/ABC?host=chat.example.com:8443")).toEqual({ + code: "ABC", + host: "chat.example.com:8443", + }); + }); + + it("URL-decodes the code", () => { + expect(parseInviteLink("owncord://invite/a%20b")).toEqual({ code: "a b" }); + }); + + it("tolerates a trailing slash", () => { + expect(parseInviteLink("owncord://invite/ABC/")).toEqual({ code: "ABC" }); + }); + + it("rejects a non-owncord scheme", () => { + expect(parseInviteLink("https://example.com/invite/ABC")).toBeNull(); + }); + + it("rejects a link with no code", () => { + expect(parseInviteLink("owncord://invite/")).toBeNull(); + expect(parseInviteLink("owncord://")).toBeNull(); + }); +}); diff --git a/Client/tauri-client/tests/unit/window-state-restore.test.ts b/Client/tauri-client/tests/unit/window-state-restore.test.ts index 8cf2f2ac..dca1b4a4 100644 --- a/Client/tauri-client/tests/unit/window-state-restore.test.ts +++ b/Client/tauri-client/tests/unit/window-state-restore.test.ts @@ -2,15 +2,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Shared mutable state read lazily by the mock factories below. const h = vi.hoisted(() => ({ - settings: {} as Record, monitors: [] as Array<{ position: { x: number; y: number }; size: { width: number; height: number }; }>, monitorsError: null as Error | null, - setPosition: vi.fn(), - setSize: vi.fn(), - maximize: vi.fn(), + maximized: false, + outerPos: { x: 0, y: 0 }, + outerSize: { width: 1280, height: 720 }, + center: vi.fn(), })); vi.mock("@lib/logger", () => ({ @@ -22,160 +22,98 @@ vi.mock("@lib/logger", () => ({ }), })); -vi.mock("@tauri-apps/api/core", () => ({ - invoke: (cmd: string) => { - if (cmd === "get_settings") return Promise.resolve(h.settings); - return Promise.resolve(undefined); - }, -})); - vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ - maximize: h.maximize, - setPosition: h.setPosition, - setSize: h.setSize, - onMoved: vi.fn().mockResolvedValue(() => {}), - onResized: vi.fn().mockResolvedValue(() => {}), - outerPosition: vi.fn(), - outerSize: vi.fn(), - isMaximized: vi.fn().mockResolvedValue(false), - isMinimized: vi.fn().mockResolvedValue(false), + isMaximized: () => Promise.resolve(h.maximized), + outerPosition: () => Promise.resolve(h.outerPos), + outerSize: () => Promise.resolve(h.outerSize), + center: h.center, }), availableMonitors: () => h.monitorsError !== null ? Promise.reject(h.monitorsError) : Promise.resolve(h.monitors), - PhysicalPosition: class { - constructor( - public x: number, - public y: number, - ) {} - }, - PhysicalSize: class { - constructor( - public width: number, - public height: number, - ) {} - }, })); const PRIMARY = { position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 } }; -function setSaved(state: Record): void { - h.settings = { windowState: state }; -} - describe("window-state restore validation", () => { beforeEach(() => { vi.resetModules(); - h.settings = {}; h.monitors = [PRIMARY]; h.monitorsError = null; - h.setPosition.mockClear(); - h.setSize.mockClear(); - h.maximize.mockClear(); + h.maximized = false; + h.outerPos = { x: 200, y: 150 }; + h.outerSize = { width: 1280, height: 720 }; + h.center.mockClear(); }); describe("isRectOnScreen", () => { it("accepts a rect fully inside a monitor", async () => { const { isRectOnScreen } = await import("@lib/window-state"); - expect( - isRectOnScreen([PRIMARY], { x: 100, y: 100, width: 1280, height: 720, maximized: false }), - ).toBe(true); + expect(isRectOnScreen([PRIMARY], { x: 100, y: 100, width: 1280, height: 720 })).toBe(true); }); it("rejects a rect far off-screen", async () => { const { isRectOnScreen } = await import("@lib/window-state"); - expect( - isRectOnScreen([PRIMARY], { x: -5000, y: 100, width: 1280, height: 720, maximized: false }), - ).toBe(false); + expect(isRectOnScreen([PRIMARY], { x: -5000, y: 100, width: 1280, height: 720 })).toBe(false); }); it("accepts a rect on a secondary monitor left of primary", async () => { const secondary = { position: { x: -1920, y: 0 }, size: { width: 1920, height: 1080 } }; const { isRectOnScreen } = await import("@lib/window-state"); expect( - isRectOnScreen([PRIMARY, secondary], { - x: -1800, - y: 50, - width: 1280, - height: 720, - maximized: false, - }), + isRectOnScreen([PRIMARY, secondary], { x: -1800, y: 50, width: 1280, height: 720 }), ).toBe(true); }); it("rejects a rect whose title bar is below every monitor", async () => { const { isRectOnScreen } = await import("@lib/window-state"); - expect( - isRectOnScreen([PRIMARY], { x: 100, y: 1075, width: 1280, height: 720, maximized: false }), - ).toBe(false); + expect(isRectOnScreen([PRIMARY], { x: 100, y: 1075, width: 1280, height: 720 })).toBe(false); }); it("rejects a rect with too little horizontal overlap", async () => { const { isRectOnScreen } = await import("@lib/window-state"); // Only 50px of the window remains on-screen at the right edge. - expect( - isRectOnScreen([PRIMARY], { x: 1870, y: 100, width: 1280, height: 720, maximized: false }), - ).toBe(false); + expect(isRectOnScreen([PRIMARY], { x: 1870, y: 100, width: 1280, height: 720 })).toBe(false); }); }); - describe("initWindowState", () => { - it("restores an on-screen saved position", async () => { - setSaved({ x: 200, y: 150, width: 1280, height: 720, maximized: false }); + describe("initWindowState off-screen guard", () => { + it("re-centers when the restored window is off-screen", async () => { + h.outerPos = { x: -5000, y: -5000 }; const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.setPosition).toHaveBeenCalledTimes(1); - expect(h.setPosition.mock.calls[0]?.[0]).toMatchObject({ x: 200, y: 150 }); - expect(h.setSize).toHaveBeenCalledTimes(1); - expect(h.setSize.mock.calls[0]?.[0]).toMatchObject({ width: 1280, height: 720 }); + await initWindowState(); + expect(h.center).toHaveBeenCalledTimes(1); }); - it("skips restore when the saved position is off-screen", async () => { - setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: false }); + it("does not re-center when the restored window is on-screen", async () => { + h.outerPos = { x: 200, y: 150 }; const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.setPosition).not.toHaveBeenCalled(); - expect(h.setSize).not.toHaveBeenCalled(); + await initWindowState(); + expect(h.center).not.toHaveBeenCalled(); }); - it("restores unchecked when availableMonitors fails", async () => { - setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: false }); + it("does not re-center (or query monitors) when maximized", async () => { + h.maximized = true; + h.outerPos = { x: -5000, y: -5000 }; + const { initWindowState } = await import("@lib/window-state"); + await initWindowState(); + expect(h.center).not.toHaveBeenCalled(); + }); + + it("leaves placement untouched when availableMonitors fails", async () => { + h.outerPos = { x: -5000, y: -5000 }; h.monitorsError = new Error("not supported"); const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.setPosition).toHaveBeenCalledTimes(1); - expect(h.setSize).toHaveBeenCalledTimes(1); + await initWindowState(); + expect(h.center).not.toHaveBeenCalled(); }); - it("restores unchecked when no monitors are reported", async () => { - setSaved({ x: 300, y: 300, width: 1280, height: 720, maximized: false }); + it("leaves placement untouched when no monitors are reported", async () => { + h.outerPos = { x: -5000, y: -5000 }; h.monitors = []; const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.setPosition).toHaveBeenCalledTimes(1); - }); - - it("maximizes without querying position when saved maximized", async () => { - setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: true }); - const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.maximize).toHaveBeenCalledTimes(1); - expect(h.setPosition).not.toHaveBeenCalled(); - }); - - it("ignores saved state with non-finite coordinates", async () => { - setSaved({ x: NaN, y: 100, width: 1280, height: 720, maximized: false }); - const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.setPosition).not.toHaveBeenCalled(); - expect(h.maximize).not.toHaveBeenCalled(); - }); - - it("ignores saved state with non-positive size", async () => { - setSaved({ x: 100, y: 100, width: 0, height: 720, maximized: false }); - const { initWindowState } = await import("@lib/window-state"); - (await initWindowState())(); - expect(h.setPosition).not.toHaveBeenCalled(); + await initWindowState(); + expect(h.center).not.toHaveBeenCalled(); }); }); }); diff --git a/Client/tauri-client/tests/unit/window-state.test.ts b/Client/tauri-client/tests/unit/window-state.test.ts index 3fb08ae2..a653a5d1 100644 --- a/Client/tauri-client/tests/unit/window-state.test.ts +++ b/Client/tauri-client/tests/unit/window-state.test.ts @@ -10,11 +10,7 @@ vi.mock("@lib/logger", () => ({ }), })); -// Mock Tauri APIs as unavailable by default -vi.mock("@tauri-apps/api/core", () => { - throw new Error("Not in Tauri"); -}); - +// Tauri window API unavailable (non-Tauri context, e.g. dev browser). vi.mock("@tauri-apps/api/window", () => { throw new Error("Not in Tauri"); }); @@ -24,11 +20,8 @@ describe("window-state", () => { vi.resetModules(); }); - it("initWindowState returns a cleanup function when Tauri unavailable", async () => { + it("initWindowState resolves without throwing when Tauri is unavailable", async () => { const { initWindowState } = await import("@lib/window-state"); - const cleanup = await initWindowState(); - expect(typeof cleanup).toBe("function"); - // Should be a no-op - cleanup(); + await expect(initWindowState()).resolves.toBeUndefined(); }); });