mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+222
-19
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user