feat(client): add single-instance/autostart/deep-link; move window-state to plugin

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) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-24 10:44:06 +02:00
co-authored by Claude Opus 4.8
parent ed59925073
commit f3c6745c0b
16 changed files with 628 additions and 322 deletions
+20
View File
@@ -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",
+2
View File
@@ -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",
+222 -19
View File
@@ -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",
+9
View File
@@ -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"] }
@@ -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",
+35 -2
View File
@@ -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::<Vec<&'static str>>,
))
.plugin(tauri_plugin_deep_link::init());
match builder
.manage(ws_proxy::WsState::new())
.manage(livekit_proxy::LiveKitProxyState::new())
.manage(http_proxy::HttpProxyState::new())
@@ -77,6 +77,11 @@
"windows": {
"installMode": "passive"
}
},
"deep-link": {
"desktop": {
"schemes": ["owncord"]
}
}
}
}
@@ -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,
+103
View File
@@ -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/<code>
* owncord://invite/<code>?host=<host>
* owncord://<code> (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/<code>` or bare `owncord://<code>`.
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<void> {
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) });
}
}
+38 -187
View File
@@ -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<string, unknown>) => Promise<unknown>) | 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<void> {
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<WindowState | null> {
const invoke = await invokePromise;
if (!invoke) return null;
try {
const all = (await invoke("get_settings")) as Record<string, unknown>;
const raw = all[STORAGE_KEY];
if (raw && typeof raw === "object") {
const s = raw as Record<string, unknown>;
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<boolean> {
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<void> {
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<typeof setTimeout> | 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();
}
};
}
+29
View File
@@ -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();
@@ -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);
},
};
}
@@ -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 ?? "";
},
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { parseInviteLink } from "@lib/deep-link";
describe("parseInviteLink", () => {
it("parses owncord://invite/<code>", () => {
expect(parseInviteLink("owncord://invite/ABC123")).toEqual({ code: "ABC123" });
});
it("parses a bare owncord://<code>", () => {
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();
});
});
@@ -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<string, unknown>,
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<string, unknown>): 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();
});
});
});
@@ -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();
});
});