diff --git a/docs/architecture.md b/docs/architecture.md index 21a3923..c14491f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,3 +80,7 @@ Versioned library and Collections owners capture account/epoch authority for eve ## Delivery boundary: journal-vault Journal uses a separately protected envelope and private-form recovery. Account, epoch and genuine borrowed-owner closure revoke access before any late read or crypto result can publish. This clears live references and views, without claiming allocator zeroization or remote deletion. The Journal view is wired in a later slice. + +## Delivery boundary: native-link-transport + +The local main window alone may acknowledge bounded note intents or request validated HTTP(S) source opening. Commands independently verify the current injected caller URL. External URLs receive a fixed OS open verb with no argument expansion; arbitrary navigation and filesystem commands are unavailable. diff --git a/web/src-tauri/Cargo.lock b/web/src-tauri/Cargo.lock index fc2347d..8f33864 100644 --- a/web/src-tauri/Cargo.lock +++ b/web/src-tauri/Cargo.lock @@ -47,6 +47,137 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "atk" version = "0.18.2" @@ -157,6 +288,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.1.1" @@ -347,6 +491,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -720,6 +873,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -737,6 +917,36 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -859,6 +1069,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1202,6 +1425,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1719,6 +1948,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -2121,11 +2356,23 @@ name = "orbyn-notes" version = "0.1.0" dependencies = [ "serde", + "serde_json", "tauri", "tauri-build", + "tauri-plugin-single-instance", "windows-sys 0.61.2", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2151,6 +2398,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2292,6 +2545,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2324,6 +2588,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2575,6 +2853,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2878,6 +3169,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3274,6 +3575,22 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.2" @@ -3373,6 +3690,19 @@ dependencies = [ "toml 1.2.1+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3643,9 +3973,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3695,6 +4037,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.27" @@ -4383,6 +4736,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.5", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.10", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.5", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4448,3 +4871,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.5", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.10", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.10", + "winnow 1.0.5", +] diff --git a/web/src-tauri/Cargo.toml b/web/src-tauri/Cargo.toml index 36957f5..e9a1c53 100644 --- a/web/src-tauri/Cargo.toml +++ b/web/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "orbyn-notes" version = "0.1.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.90" description = "Orbyn Notes local Windows shell" license = "PolyForm-Strict-1.0.0" @@ -12,9 +12,13 @@ tauri-build = { version = "=2.7.4", features = [] } [dependencies] tauri = { version = "=2.12.5", features = [] } serde = { version = "1", features = ["derive"] } +tauri-plugin-single-instance = { version = "=2.5.5", default-features = false } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell"] } + +[dev-dependencies] +serde_json = "1" [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/web/src-tauri/build.rs b/web/src-tauri/build.rs index 9ad3763..f8a80a6 100644 --- a/web/src-tauri/build.rs +++ b/web/src-tauri/build.rs @@ -1,6 +1,13 @@ fn main() { tauri_build::try_build(tauri_build::Attributes::new().app_manifest( - tauri_build::AppManifest::new().commands(&["finish_close", "show_system_menu"]), + tauri_build::AppManifest::new().commands(&[ + "finish_close", + "show_system_menu", + "open_external_url", + "note_link_snapshot", + "note_link_ack", + "note_link_retire", + ]), )) .expect("failed to build native application resources"); } diff --git a/web/src-tauri/capabilities/main-window.json b/web/src-tauri/capabilities/main-window.json index 195c296..806f6bb 100644 --- a/web/src-tauri/capabilities/main-window.json +++ b/web/src-tauri/capabilities/main-window.json @@ -1,13 +1,13 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-window", - "description": "Only the local main window can manage its caption and guarded close.", + "description": "The local main window can manage its caption, guarded close and validated HTTP(S) source opening.", "windows": ["main"], "local": true, "permissions": [ "core:event:allow-listen", "core:event:allow-unlisten", "core:window:allow-is-maximized", "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-start-dragging", - "core:window:allow-close", "allow-finish-close", "allow-show-system-menu" + "core:window:allow-close", "allow-finish-close", "allow-show-system-menu", "allow-open-external-url", "allow-note-link-snapshot", "allow-note-link-ack", "allow-note-link-retire" ] } diff --git a/web/src-tauri/permissions/autogenerated/note_link_ack.toml b/web/src-tauri/permissions/autogenerated/note_link_ack.toml new file mode 100644 index 0000000..61998a4 --- /dev/null +++ b/web/src-tauri/permissions/autogenerated/note_link_ack.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-note-link-ack" +description = "Enables the note_link_ack command without any pre-configured scope." +commands.allow = ["note_link_ack"] + +[[permission]] +identifier = "deny-note-link-ack" +description = "Denies the note_link_ack command without any pre-configured scope." +commands.deny = ["note_link_ack"] diff --git a/web/src-tauri/permissions/autogenerated/note_link_retire.toml b/web/src-tauri/permissions/autogenerated/note_link_retire.toml new file mode 100644 index 0000000..46bd6af --- /dev/null +++ b/web/src-tauri/permissions/autogenerated/note_link_retire.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-note-link-retire" +description = "Enables the note_link_retire command without any pre-configured scope." +commands.allow = ["note_link_retire"] + +[[permission]] +identifier = "deny-note-link-retire" +description = "Denies the note_link_retire command without any pre-configured scope." +commands.deny = ["note_link_retire"] diff --git a/web/src-tauri/permissions/autogenerated/note_link_snapshot.toml b/web/src-tauri/permissions/autogenerated/note_link_snapshot.toml new file mode 100644 index 0000000..48dd14d --- /dev/null +++ b/web/src-tauri/permissions/autogenerated/note_link_snapshot.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-note-link-snapshot" +description = "Enables the note_link_snapshot command without any pre-configured scope." +commands.allow = ["note_link_snapshot"] + +[[permission]] +identifier = "deny-note-link-snapshot" +description = "Denies the note_link_snapshot command without any pre-configured scope." +commands.deny = ["note_link_snapshot"] diff --git a/web/src-tauri/permissions/autogenerated/open_external_url.toml b/web/src-tauri/permissions/autogenerated/open_external_url.toml new file mode 100644 index 0000000..3a89010 --- /dev/null +++ b/web/src-tauri/permissions/autogenerated/open_external_url.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-external-url" +description = "Enables the open_external_url command without any pre-configured scope." +commands.allow = ["open_external_url"] + +[[permission]] +identifier = "deny-open-external-url" +description = "Denies the open_external_url command without any pre-configured scope." +commands.deny = ["open_external_url"] diff --git a/web/src-tauri/src/external_url.rs b/web/src-tauri/src/external_url.rs new file mode 100644 index 0000000..71dfb9a --- /dev/null +++ b/web/src-tauri/src/external_url.rs @@ -0,0 +1,141 @@ +const ERROR: &str = "Could not open the source. Copy its URL and open it in your browser."; + +pub(crate) fn validate(value: &str) -> Result { + if value.encode_utf16().count() > 8192 + || value + .chars() + .any(|c| c <= '\u{20}' || c == '\u{7f}' || c == '\\') + { + return Err(ERROR.into()); + } + let Some((scheme, rest)) = value.split_once("://") else { + return Err(ERROR.into()); + }; + if !scheme.eq_ignore_ascii_case("https") && !scheme.eq_ignore_ascii_case("http") { + return Err(ERROR.into()); + } + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if authority.is_empty() || authority.contains('@') { + return Err(ERROR.into()); + } + let url = tauri::Url::parse(value).map_err(|_| ERROR.to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + { + return Err(ERROR.into()); + } + Ok(url) +} + +#[tauri::command] +pub(crate) async fn open_external_url( + window: tauri::WebviewWindow, + url: String, +) -> Result<(), String> { + super::verify_caller(&window).map_err(|_| ERROR.to_string())?; + let url = validate(&url)?; + #[cfg(windows)] + { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let live_window = window.clone(); + window + .run_on_main_thread(move || { + let handoff = || -> Result<(), String> { + // Recheck the injected live owner at dispatch time; never carry a bare HWND across the queue. + super::verify_caller(&live_window).map_err(|_| ERROR.to_string())?; + let hwnd = live_window.hwnd().map_err(|_| ERROR.to_string())?; + let destination: Vec = + url.as_str().encode_utf16().chain(Some(0)).collect(); + let verb = [b'o' as u16, b'p' as u16, b'e' as u16, b'n' as u16, 0]; + // SAFETY: this is the existing Wry STA thread and its live owned window. Both terminated + // buffers stay live through the call; no parameters or executable path are accepted. + let result = unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + hwnd.0 as _, + verb.as_ptr(), + destination.as_ptr(), + std::ptr::null(), + std::ptr::null(), + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL, + ) + }; + if (result as isize) > 32 { + Ok(()) + } else { + Err(ERROR.into()) + } + }; + let _ = sender.send(handoff()); + }) + .map_err(|_| ERROR.to_string())?; + tauri::async_runtime::spawn_blocking(move || receiver.recv()) + .await + .map_err(|_| ERROR.to_string())? + .map_err(|_| ERROR.to_string())? + } + #[cfg(not(windows))] + { + let _ = url; + Err(ERROR.into()) + } +} + +#[cfg(test)] +mod tests { + use super::validate; + + #[test] + fn accepts_only_complete_web_destinations() { + for value in [ + "http://example.test/", + "HTTPS://EXAMPLE.test/path?q=one&next=two#section", + "https://example.test/日本語?q=✓", + "http://127.0.0.1:9910/", + "https://[::1]:9910/", + ] { + assert!(validate(value).is_ok()); + } + } + + #[test] + fn rejects_other_schemes_credentials_and_ambiguous_input() { + for value in [ + "", + "//example.test/path", + "https:example.test", + "https:///example.test", + "file:///C:/Windows/win.ini", + "mailto:synthetic@example.test", + "javascript:alert(1)", + "data:text/plain,hello", + "ms-settings:privacy", + "C:/Windows/notepad.exe", + r"\\server\share", + r"https://example.test\other", + "https://user:synthetic@example.test/", + "https://user@example.test/", + "https://@example.test/", + "https://example.test/a b", + " https://example.test/", + "https://example.test/\n", + "https://example.test/\0", + "https://example.test/\u{7f}", + "https://", + "https://?q=missing-host", + ] { + assert!(validate(value).is_err()); + } + } + + #[test] + fn bounds_utf16_input_and_never_echoes_a_rejected_destination() { + let prefix = "https://example.test/"; + assert!(validate(&(prefix.to_owned() + &"a".repeat(8192 - prefix.len()))).is_ok()); + assert!(validate(&(prefix.to_owned() + &"a".repeat(8193 - prefix.len()))).is_err()); + assert!(validate(&(prefix.to_owned() + &"📝".repeat(4096))).is_err()); + let secret = "https://user:synthetic-secret@example.test/"; + assert!(!validate(secret).unwrap_err().contains(secret)); + } +} diff --git a/web/src-tauri/src/main.rs b/web/src-tauri/src/main.rs index 4253e51..775fd09 100644 --- a/web/src-tauri/src/main.rs +++ b/web/src-tauri/src/main.rs @@ -3,6 +3,9 @@ use std::sync::Mutex; use tauri::{Emitter, Manager, WebviewWindow, WindowEvent}; +mod external_url; +mod native_note_link; + const MAX_REQUEST_ID: u64 = 9_007_199_254_740_991; #[derive(Default)] @@ -122,20 +125,18 @@ async fn show_system_menu(window: WebviewWindow) -> Result<(), String> { hwnd, std::ptr::null(), ); - if command != 0 { - if PostMessageW(hwnd, WM_SYSCOMMAND, command as usize, 0) == 0 { - let _ = sender.send(Err("System command is unavailable.".to_string())); - return; - } + if command != 0 && PostMessageW(hwnd, WM_SYSCOMMAND, command as usize, 0) == 0 { + let _ = sender.send(Err("System command is unavailable.".to_string())); + return; } let _ = sender.send(Ok(())); } }) .map_err(|e| e.to_string())?; - return tauri::async_runtime::spawn_blocking(move || receiver.recv()) + tauri::async_runtime::spawn_blocking(move || receiver.recv()) .await .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())? } #[cfg(not(windows))] Ok(()) @@ -143,8 +144,19 @@ async fn show_system_menu(window: WebviewWindow) -> Result<(), String> { fn main() { tauri::Builder::default() + .manage(Mutex::new(native_note_link::initial_broker())) + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + native_note_link::activate(app, args); + })) .manage(Mutex::new(CloseRequests::default())) - .invoke_handler(tauri::generate_handler![finish_close, show_system_menu]) + .invoke_handler(tauri::generate_handler![ + finish_close, + show_system_menu, + external_url::open_external_url, + native_note_link::note_link_snapshot, + native_note_link::note_link_ack, + native_note_link::note_link_retire + ]) .setup(|app| { tauri::WebviewWindowBuilder::from_config(app, &app.config().app.windows[0])? .on_navigation(local_url) diff --git a/web/src-tauri/src/native_note_link.rs b/web/src-tauri/src/native_note_link.rs new file mode 100644 index 0000000..5bddbc0 --- /dev/null +++ b/web/src-tauri/src/native_note_link.rs @@ -0,0 +1,278 @@ +use std::{ffi::OsString, sync::Mutex}; +use tauri::{Emitter, Manager, WebviewWindow}; + +const MAX_SEQUENCE: u64 = 9_007_199_254_740_991; +const PREFIX: &str = "orbyn-notes://note/v1/"; + +#[derive(Clone, serde::Serialize)] +pub(crate) struct Pending { + sequence: u64, + uri: String, +} +#[derive(serde::Serialize)] +pub(crate) struct Snapshot { + sequence: u64, + pending: Option, +} +#[derive(Clone, serde::Serialize)] +pub(crate) struct Sequence { + sequence: u64, +} +#[derive(Default)] +pub(crate) struct Broker { + sequence: u64, + pending: Option, +} +impl Broker { + fn advance(&mut self) -> Result { + if self.sequence >= MAX_SEQUENCE { + self.pending = None; + return Err("Reference sequence is exhausted.".into()); + } + self.sequence += 1; + Ok(self.sequence) + } + fn accept(&mut self, uri: String) -> Result { + let sequence = self.advance()?; + self.pending = Some(Pending { sequence, uri }); + Ok(Sequence { sequence }) + } + fn snapshot(&self) -> Snapshot { + Snapshot { + sequence: self.sequence, + pending: self.pending.clone(), + } + } + fn ack(&mut self, sequence: u64) -> bool { + if sequence == 0 || self.pending.as_ref().map(|p| p.sequence) != Some(sequence) { + return false; + } + self.pending = None; + true + } + fn retire(&mut self) -> Result { + self.pending = None; + Ok(Sequence { + sequence: self.advance()?, + }) + } +} + +// Envelope admission only. Independent TS decoding validates the canonical +// JSON, UTF8 identity and scope before the guarded application handoff. +fn envelope(uri: &str) -> bool { + if !uri.is_ascii() || uri.len() > 8192 { + return false; + } + let Some(payload) = uri.strip_prefix(PREFIX) else { + return false; + }; + if payload.is_empty() || payload.len() > 8000 || payload.len() % 4 == 1 { + return false; + } + let mut last = 0; + for byte in payload.bytes() { + last = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'-' => 62, + b'_' => 63, + _ => return false, + }; + } + match payload.len() % 4 { + 2 => last & 15 == 0, + 3 => last & 3 == 0, + _ => true, + } +} +pub(crate) fn startup(args: impl IntoIterator) -> Option { + let mut args = args.into_iter(); + args.next()?; // Executable metadata may contain any valid OS path. + let operand = args.next()?; + if args.next().is_some() { + return None; + } + let uri = operand.to_str()?; + envelope(uri).then(|| uri.to_owned()) +} +enum Activation { + Reveal, + Reference(String), + Ignore, +} +fn repeated(args: &[String]) -> Activation { + match args.len() { + 1 => Activation::Reveal, + 2 if envelope(&args[1]) => Activation::Reference(args[1].clone()), + _ => Activation::Ignore, + } +} +pub(crate) fn initial_broker() -> Broker { + let mut broker = Broker::default(); + if let Some(uri) = startup(std::env::args_os()) { + let _ = broker.accept(uri); + } + broker +} +pub(crate) fn activate(app: &tauri::AppHandle, args: Vec) { + let activation = repeated(&args); + if matches!(activation, Activation::Ignore) { + return; + } + // Native window reveal is independent of guarded renderer navigation. + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } + let Activation::Reference(uri) = activation else { + return; + }; + let state = app.state::>(); + let Ok(mut broker) = state.lock() else { + return; + }; + let Ok(sequence) = broker.accept(uri) else { + return; + }; + drop(broker); + if let Some(main) = app.get_webview_window("main") { + let _ = main.emit_to( + tauri::EventTarget::WebviewWindow { + label: "main".into(), + }, + "notes-reference-pending", + sequence, + ); + } +} +#[tauri::command] +pub(crate) fn note_link_snapshot(window: WebviewWindow) -> Result { + super::verify_caller(&window)?; + let state = window.state::>(); + let broker = state + .lock() + .map_err(|_| "Reference broker is unavailable.")?; + Ok(broker.snapshot()) +} +#[tauri::command] +pub(crate) fn note_link_ack(window: WebviewWindow, sequence: u64) -> Result { + super::verify_caller(&window)?; + let state = window.state::>(); + let mut broker = state + .lock() + .map_err(|_| "Reference broker is unavailable.")?; + Ok(broker.ack(sequence)) +} +#[tauri::command] +pub(crate) fn note_link_retire(window: WebviewWindow) -> Result { + super::verify_caller(&window)?; + let state = window.state::>(); + let mut broker = state + .lock() + .map_err(|_| "Reference broker is unavailable.")?; + broker.retire() +} + +#[cfg(test)] +mod tests { + use super::*; + const URI: &str = "orbyn-notes://note/v1/eyJ2IjoxfQ"; + #[test] + fn raw_arguments_are_exact_and_executable_path_is_not_uri_input() { + assert_eq!( + startup([OsString::from("C:/用户/Notes.exe"), URI.into()]), + Some(URI.into()) + ); + assert!( + matches!(repeated(&["C:/用户/Notes.exe".into(), URI.into()]), Activation::Reference(value) if value == URI) + ); + for uri in [ + "orbyn-notes://note/v1/YR", + "orbyn-notes://note/v1/A", + "orbyn-notes://note/v1/YQ=", + "orbyn-notes://note/v1/", + "orbyn-notes://note/v2/YQ", + "ORBYN-notes://note/v1/YQ", + "orbyn-notes://note/v1/YQ#x", + "orbyn-notes://note/v1/YQ?x", + "orbyn-notes://note/v1/%59Q", + "orbyn-notes://note/v1/é", + "orbyn-notes://note/v1/�", + " orbyn-notes://note/v1/YQ", + "orbyn-notes://note/v1/YQ\n", + ] { + assert!(!envelope(uri), "admitted malformed operand"); + } + assert!(!envelope(&format!("{PREFIX}{}", "A".repeat(8001)))); + assert!(startup(["app.exe".into()]).is_none()); + assert!(matches!( + repeated(&["C:/用户/Notes.exe".into()]), + Activation::Reveal + )); + assert!(matches!(repeated(&[]), Activation::Ignore)); + assert!(matches!( + repeated(&["app.exe".into(), "--unknown".into()]), + Activation::Ignore + )); + assert!(matches!( + repeated(&["app.exe".into(), "orbyn-notes://note/v1/�".into()]), + Activation::Ignore + )); + assert!(startup(["app.exe".into(), URI.into(), URI.into()]).is_none()); + assert!(matches!( + repeated(&["app.exe".into(), URI.into(), "extra".into()]), + Activation::Ignore + )); + } + #[cfg(windows)] + #[test] + fn malformed_utf16_operand_is_rejected_without_lossy_startup_alias() { + use std::os::windows::ffi::OsStringExt; + assert!(startup(["app.exe".into(), OsString::from_wide(&[0xd800])]).is_none()); + } + #[test] + fn shared_exact_valid_reference_vectors_pass_transport_without_normalization() { + let vectors: serde_json::Value = + serde_json::from_str(include_str!("../../tests/vectors/note-reference.json")).unwrap(); + for vector in vectors["valid"].as_array().unwrap() { + let uri = vector["uri"].as_str().unwrap(); + assert!(envelope(uri)); + assert_eq!( + startup(["C:/用户/Notes.exe".into(), uri.into()]), + Some(uri.into()) + ); + } + } + #[test] + fn bounded_replacement_conditional_ack_and_retirement() { + let mut broker = Broker::default(); + assert!(broker.snapshot().pending.is_none()); + let first = broker.accept(URI.into()).unwrap().sequence; + let second = broker.accept(URI.into()).unwrap().sequence; + assert!(!broker.ack(first)); + assert_eq!(broker.snapshot().pending.unwrap().sequence, second); + let barrier = broker.retire().unwrap().sequence; + assert!(barrier > second); + assert!(!broker.ack(second)); + assert!(broker.snapshot().pending.is_none()); + let newer = broker.accept(URI.into()).unwrap().sequence; + assert!(newer > barrier); + assert!(broker.ack(newer)); + assert!(!broker.ack(newer)); + } + #[test] + fn exhaustion_never_wraps_or_preserves_replayable_pending() { + let mut broker = Broker { + sequence: MAX_SEQUENCE - 1, + pending: None, + }; + assert_eq!(broker.accept(URI.into()).unwrap().sequence, MAX_SEQUENCE); + assert!(broker.accept(URI.into()).is_err()); + assert!(broker.pending.is_none()); + assert!(broker.retire().is_err()); + assert_eq!(broker.sequence, MAX_SEQUENCE); + } +} diff --git a/web/src/desktop/DesktopShell.tsx b/web/src/desktop/DesktopShell.tsx index 1e39e0a..2bc9ef3 100644 --- a/web/src/desktop/DesktopShell.tsx +++ b/web/src/desktop/DesktopShell.tsx @@ -105,8 +105,8 @@ function NativeShell({ children }: { children: ReactNode }) {
- - + +
diff --git a/web/src/desktop/external.ts b/web/src/desktop/external.ts new file mode 100644 index 0000000..4ad0ee6 --- /dev/null +++ b/web/src/desktop/external.ts @@ -0,0 +1,30 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +const FAILURE = + "Could not open the source. Copy its URL and open it in your browser."; + +/** Native-only handoff. Browser presentation uses a normal safe external anchor. */ +export async function openExternalUrl(url: string): Promise { + try { + if (typeof url !== "string" || url.length > 8192) throw new Error(); + const authority = /^https?:\/\/([^/?#]+)/i.exec(url)?.[1]; + if ( + !authority || + authority.includes("@") || + /[\u0000-\u0020\u007f\\]/.test(url) + ) + throw new Error(); + const parsed = new URL(url); + if ( + !["http:", "https:"].includes(parsed.protocol) || + !parsed.hostname || + parsed.username || + parsed.password || + !isTauri() + ) + throw new Error(); + await invoke("open_external_url", { url }); + } catch { + throw new Error(FAILURE); + } +} diff --git a/web/src/desktop/noteLinks.ts b/web/src/desktop/noteLinks.ts new file mode 100644 index 0000000..1ddb9bf --- /dev/null +++ b/web/src/desktop/noteLinks.ts @@ -0,0 +1,253 @@ +import type { NoteTarget } from "../links/contracts"; +import { decodeNoteReference } from "../links/reference"; + +export interface NativeNoteLinkBridge { + listen(onSequence: (sequence: unknown) => void): Promise<() => void>; + snapshot(): Promise; + ack(sequence: number): Promise; + retire(): Promise; +} +export interface NativeNoteLinkIngress { + start( + handoff: (target: NoteTarget, sequence: number) => boolean, + onInvalid?: () => void, + onError?: () => void, + ): Promise; + refresh(): Promise; + retire(): Promise; + stop(): Promise; +} +function sequence(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} +function object(value: unknown, keys: string[]): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== keys.length || + !keys.every((key) => Object.hasOwn(value, key)) + ) + throw new Error("Invalid native reference response."); + return value as Record; +} +export function createNativeNoteLinkIngress( + bridge: NativeNoteLinkBridge, +): NativeNoteLinkIngress { + let connection = 0, + generation = 0, + watermark = 0, + observed = 0; + let barriers = 0, + failedBarrier = false, + running = false, + listening = false; + let unlisten: (() => void) | null = null; + let handoff: ((target: NoteTarget, sequence: number) => boolean) | null = + null; + let onInvalid: (() => void) | undefined; + let onError: (() => void) | undefined; + let latestRequest: object | null = null; + function backgroundRefresh() { + const ticket = connection, + captured = generation, + work = refresh(), + request = latestRequest; + void work.catch(() => { + if ( + !running || + ticket !== connection || + captured !== generation || + request !== latestRequest + ) + return; + try { + onError?.(); + } catch { + /* Notice does not control broker state. */ + } + }); + } + const paused = () => barriers > 0 || failedBarrier; + async function refresh(): Promise { + if (!running || !listening || paused()) return; + const ticket = connection, + captured = generation; + const request = {}; + latestRequest = request; + try { + const raw = await bridge.snapshot(); + if ( + !running || + paused() || + ticket !== connection || + captured !== generation + ) + return; + const snapshot = object(raw, ["sequence", "pending"]); + if (!sequence(snapshot.sequence)) + throw new Error("Invalid native reference sequence."); + const current = snapshot.sequence; + if (current < observed || current <= watermark) return; + observed = Math.max(observed, current); + if (snapshot.pending === null) { + watermark = Math.max(watermark, current); + return; + } + const pending = object(snapshot.pending, ["sequence", "uri"]); + if ( + pending.sequence !== current || + current === 0 || + typeof pending.uri !== "string" || + pending.uri.length > 8192 + ) + throw new Error("Invalid native reference pending value."); + let target: NoteTarget; + try { + target = decodeNoteReference(pending.uri); + } catch { + watermark = Math.max(watermark, current); + try { + onInvalid?.(); + } catch { + /* Notice does not control retirement. */ + } + if (ticket === connection && captured === generation && !paused()) + await bridge.ack(current); + return; + } + if (!handoff?.(target, current)) return; + if ( + !running || + paused() || + ticket !== connection || + captured !== generation + ) + return; + // Consumption is a validated controller handoff, never navigation success. + watermark = Math.max(watermark, current); + await bridge.ack(current); + } catch (error) { + if ( + !running || + ticket !== connection || + captured !== generation || + request !== latestRequest + ) + return; + throw error; + } + } + async function retire(): Promise { + generation++; // Synchronous fence before the native command can await. + if (barriers === 0) failedBarrier = false; + barriers++; + try { + const result = object(await bridge.retire(), ["sequence"]); + if (!sequence(result.sequence)) + throw new Error("Invalid native retirement sequence."); + watermark = Math.max(watermark, result.sequence); + observed = Math.max(observed, result.sequence); + } catch (error) { + failedBarrier = true; + throw error; + } finally { + barriers--; + if (!paused() && running) backgroundRefresh(); + } + } + return { + async start(nextHandoff, nextInvalid, nextError) { + const ticket = ++connection; + generation++; + listening = false; + unlisten?.(); + unlisten = null; + running = true; + handoff = nextHandoff; + onInvalid = nextInvalid; + onError = nextError; + const cleanup = await bridge.listen((value) => { + if ( + ticket !== connection || + !running || + !sequence(value) || + value === 0 || + value <= watermark || + value <= observed + ) + return; + observed = Math.max(observed, value); + if (failedBarrier) { + try { + onError?.(); + } catch { + /* Notice is not authorization. */ + } + return; + } + backgroundRefresh(); + }); + if (!running || ticket !== connection) { + cleanup(); + return; + } + unlisten = cleanup; + listening = true; + // Listener is installed before the initial snapshot. A newer request + // also fences a late startup rejection before the caller can report it. + const work = refresh(), + request = latestRequest, + captured = generation; + try { + await work; + } catch (error) { + if ( + running && + ticket === connection && + captured === generation && + request === latestRequest + ) + throw error; + } + }, + refresh, + retire, + async stop() { + running = false; + listening = false; + connection++; + generation++; + handoff = null; + onInvalid = undefined; + onError = undefined; + unlisten?.(); + unlisten = null; + await retire(); + }, + }; +} +const nativeBridge: NativeNoteLinkBridge = { + async listen(onSequence) { + const { listen } = await import("@tauri-apps/api/event"); + return listen<{ sequence: number }>( + "notes-reference-pending", + (event) => onSequence(event.payload?.sequence), + { target: { kind: "WebviewWindow", label: "main" } }, + ); + }, + async snapshot() { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("note_link_snapshot"); + }, + async ack(value) { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("note_link_ack", { sequence: value }); + }, + async retire() { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("note_link_retire"); + }, +}; +// App lifetime: do not recreate this owner on account/epoch/React hook changes. +export const nativeNoteLinkIngress = createNativeNoteLinkIngress(nativeBridge); diff --git a/web/tests/desktop/note-link-ingress.test.ts b/web/tests/desktop/note-link-ingress.test.ts new file mode 100644 index 0000000..a4e035b --- /dev/null +++ b/web/tests/desktop/note-link-ingress.test.ts @@ -0,0 +1,467 @@ +// @vitest-environment node +import { expect, it, vi } from "vitest"; +import { + createNativeNoteLinkIngress, + nativeNoteLinkIngress, + type NativeNoteLinkBridge, +} from "../../src/desktop/noteLinks"; +import type { NoteTarget } from "../../src/links/contracts"; +import { encodeNoteReference } from "../../src/links/reference"; +const nativeWiring = vi.hoisted(() => ({ listen: vi.fn(), invoke: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: nativeWiring.listen })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: nativeWiring.invoke })); +const target = { + kind: "note" as const, + accountId: "11111111-1111-4111-8111-111111111111", + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "opaque", +}; +const uri = encodeNoteReference(target); +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function broker() { + let sequence = 0, + pending: null | { sequence: number; uri: string } = null; + const listeners = new Set<(value: unknown) => void>(); + const bridge: NativeNoteLinkBridge = { + listen: vi.fn(async (callback) => { + listeners.add(callback); + return () => listeners.delete(callback); + }), + snapshot: vi.fn(async () => structuredClone({ sequence, pending })), + ack: vi.fn(async (value) => { + if (pending?.sequence !== value) return false; + pending = null; + return true; + }), + retire: vi.fn(async () => { + pending = null; + return { sequence: ++sequence }; + }), + }; + return { + bridge, + activate(value = uri) { + pending = { sequence: ++sequence, uri: value }; + for (const listener of listeners) listener(sequence); + return sequence; + }, + notify(value: unknown) { + for (const listener of listeners) listener(value); + }, + current: () => ({ sequence, pending }), + }; +} +it("installs listener before startup snapshot and consumes only validated controller handoff", async () => { + const native = broker(); + native.activate(); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff); + expect(handoff).toHaveBeenCalledWith(target, 1); + expect(native.bridge.ack).toHaveBeenCalledWith(1); + expect(native.current().pending).toBeNull(); + expect( + vi.mocked(native.bridge.listen).mock.invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(native.bridge.snapshot).mock.invocationCallOrder[0]!, + ); + await transport.refresh(); + native.notify(1); + await turn(); + expect(handoff).toHaveBeenCalledTimes(1); +}); +it("keeps unaccepted startup bounded for explicit refresh and never equates ack with navigation", async () => { + const native = broker(); + native.activate(); + let ready = false; + const controller: unknown[] = []; + const transport = createNativeNoteLinkIngress(native.bridge); + await transport.start((value) => { + if (!ready) return false; + controller.push(value); + return true; + }); + expect(native.current().pending).not.toBeNull(); + expect(native.bridge.ack).not.toHaveBeenCalled(); + ready = true; + await transport.refresh(); + expect(controller).toEqual([target]); + expect(native.current().pending).toBeNull(); + await transport.refresh(); + expect(controller).toHaveLength(1); +}); +it("retires semantically invalid values without disclosing identity or navigating", async () => { + const native = broker(); + native.activate("orbyn-notes://note/v1/eyJ2IjoxfQ"); + const invalid = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff, invalid); + expect(invalid).toHaveBeenCalledExactlyOnceWith(); + expect(handoff).not.toHaveBeenCalled(); + expect(native.bridge.ack).toHaveBeenCalledWith(1); +}); +it("ignores older snapshots once a newer sequence event has been observed", async () => { + const native = broker(); + native.activate(); + const held = deferred(); + vi.mocked(native.bridge.snapshot).mockReturnValueOnce(held.promise); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + const start = transport.start(handoff); + await turn(); + native.activate(); + await turn(); + held.resolve({ sequence: 1, pending: { sequence: 1, uri } }); + await start; + expect(handoff.mock.calls.map((call) => call[1])).toEqual([2]); +}); +it("conditional old acknowledgment cannot erase a newer native request", async () => { + const native = broker(), + hold = deferred(), + real = vi.mocked(native.bridge.ack).getMockImplementation()!; + let first = true; + vi.mocked(native.bridge.ack).mockImplementation(async (value) => { + if (first) { + first = false; + await hold.promise; + } + return real(value); + }); + native.activate(); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge), + start = transport.start(handoff); + await turn(); + native.activate(); + await turn(); + hold.resolve(); + await start; + expect(handoff.mock.calls.map((call) => call[1])).toEqual([1, 2]); + expect(native.current().pending).toBeNull(); +}); +it("fences held snapshots at revocation and admits only a deliberately newer activation", async () => { + const native = broker(); + native.activate(); + const held = deferred(); + vi.mocked(native.bridge.snapshot).mockReturnValueOnce(held.promise); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge), + start = transport.start(handoff); + await turn(); + await transport.retire(); + held.resolve({ sequence: 1, pending: { sequence: 1, uri } }); + await start; + expect(handoff).not.toHaveBeenCalled(); + native.activate(); + await turn(); + expect(handoff).toHaveBeenCalledWith(target, 3); +}); +it("pauses through every overlapping retirement and keeps the maximum out-of-order barrier watermark", async () => { + const native = broker(), + one = deferred(), + two = deferred(); + vi.mocked(native.bridge.retire) + .mockReturnValueOnce(one.promise) + .mockReturnValueOnce(two.promise); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff); + const first = transport.retire(), + second = transport.retire(); + native.activate(); + two.resolve({ sequence: 4 }); + await second; + await transport.refresh(); + expect(handoff).not.toHaveBeenCalled(); + one.resolve({ sequence: 2 }); + await first; + await turn(); + expect(handoff).not.toHaveBeenCalled(); + native.notify(3); + await turn(); + expect(handoff).not.toHaveBeenCalled(); +}); +it("keeps failed retirement paused until explicit successful barrier retry", async () => { + const native = broker(); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff); + vi.mocked(native.bridge.retire).mockRejectedValueOnce( + new Error("IPC unavailable"), + ); + await expect(transport.retire()).rejects.toThrow(); + native.activate(); + await turn(); + expect(handoff).not.toHaveBeenCalled(); + await transport.retire(); + native.activate(); + await turn(); + expect(handoff).toHaveBeenCalledWith(target, 3); +}); +it("keeps app watermark across stop/restart and treats a new sequence for the same URI as new intent", async () => { + const native = broker(); + native.activate(); + const handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff); + await transport.stop(); + vi.mocked(native.bridge.snapshot).mockResolvedValueOnce({ + sequence: 1, + pending: { sequence: 1, uri }, + }); + await transport.start(handoff); + expect(handoff).toHaveBeenCalledTimes(1); + native.activate(); + await turn(); + expect(handoff.mock.calls.map((call) => call[1])).toEqual([1, 3]); +}); +it("start replacement with late listener cleanup never drops the original startup request", async () => { + const native = broker(); + native.activate(); + const late = deferred<() => void>(), + cleanup = vi.fn(); + vi.mocked(native.bridge.listen).mockReturnValueOnce(late.promise); + const old = vi.fn((_target: NoteTarget, _sequence: number) => true), + latest = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + const first = transport.start(old); + await transport.start(latest); + late.resolve(cleanup); + await first; + expect(cleanup).toHaveBeenCalledOnce(); + expect(old).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledWith(target, 1); +}); +it("restart while old stop barrier/listener await does not permanently pause the latest start", async () => { + const native = broker(), + late = deferred<() => void>(), + barrier = deferred(), + cleanup = vi.fn(); + vi.mocked(native.bridge.listen).mockReturnValueOnce(late.promise); + vi.mocked(native.bridge.retire).mockReturnValueOnce(barrier.promise); + const old = vi.fn((_target: NoteTarget, _sequence: number) => true), + latest = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge), + first = transport.start(old), + stop = transport.stop(); + await transport.start(latest); + late.resolve(cleanup); + await first; + native.activate(); + await turn(); + expect(latest).not.toHaveBeenCalled(); + barrier.resolve({ sequence: 2 }); + await stop; + native.activate(); + native.activate(); + await turn(); + expect(latest).toHaveBeenCalledWith(target, 3); + expect(old).not.toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalledOnce(); +}); + +it("reports background failures without raw payload and reconnect barrier clears poison", async () => { + const native = broker(), + error = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff, undefined, error); + vi.mocked(native.bridge.snapshot).mockRejectedValueOnce( + new Error("synthetic IPC failure"), + ); + native.activate(); + await turn(); + expect(error).toHaveBeenCalledExactlyOnceWith(); + expect(handoff).not.toHaveBeenCalled(); + vi.mocked(native.bridge.retire).mockRejectedValueOnce( + new Error("retirement failure"), + ); + await expect(transport.retire()).rejects.toThrow(); + native.activate(); + await turn(); + expect(handoff).not.toHaveBeenCalled(); + await transport.retire(); + await transport.start(handoff, undefined, error); + native.activate(); + await turn(); + expect(handoff).toHaveBeenCalledTimes(1); +}); + +it("new activations re-notify a failed retirement without retrying or navigating", async () => { + const native = broker(), + error = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff, undefined, error); + vi.mocked(native.bridge.retire).mockRejectedValueOnce( + new Error("failed barrier"), + ); + await expect(transport.retire()).rejects.toThrow(); + vi.mocked(native.bridge.snapshot).mockClear(); + const first = native.activate(); + await turn(); + expect(error).toHaveBeenCalledTimes(1); + native.notify(first); + native.notify(first - 1); + await turn(); + expect(error).toHaveBeenCalledTimes(1); + native.activate(); + await turn(); + expect(error).toHaveBeenCalledTimes(2); + expect(native.bridge.snapshot).not.toHaveBeenCalled(); + expect(handoff).not.toHaveBeenCalled(); +}); +it("held snapshot errors from retired generations cannot notify a newer owner", async () => { + const native = broker(), + held = deferred(), + error = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff, undefined, error); + vi.mocked(native.bridge.snapshot).mockReturnValueOnce(held.promise); + native.activate(); + await transport.retire(); + held.reject(new Error("old failure")); + await turn(); + expect(error).not.toHaveBeenCalled(); +}); +it("listener readiness gates snapshots during failed-start reconnect", async () => { + const native = broker(), + late = deferred<() => void>(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + vi.mocked(native.bridge.listen).mockRejectedValueOnce( + new Error("no listener"), + ); + await expect(transport.start(handoff)).rejects.toThrow(); + await transport.retire(); + native.activate(); + await turn(); + expect(handoff).not.toHaveBeenCalled(); + vi.mocked(native.bridge.listen).mockReturnValueOnce(late.promise); + const restart = transport.start(handoff); + await transport.refresh(); + expect(handoff).not.toHaveBeenCalled(); + late.resolve(() => {}); + await restart; + expect(handoff).toHaveBeenCalledWith(target, 2); +}); + +it.each(["snapshot", "ack"])( + "newer successful handoff suppresses a held old background %s failure", + async (kind) => { + const native = broker(), + held = deferred(), + error = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff, undefined, error); + if (kind === "snapshot") + vi.mocked(native.bridge.snapshot).mockReturnValueOnce(held.promise); + else vi.mocked(native.bridge.ack).mockReturnValueOnce(held.promise); + native.activate(); + await turn(); + native.activate(); + await turn(); + expect(handoff).toHaveBeenCalledWith(target, 2); + held.reject(new Error("old background failure")); + await turn(); + expect(error).not.toHaveBeenCalled(); + }, +); +it.each(["snapshot", "ack"])( + "newer activation fences a late initial-start %s rejection", + async (kind) => { + const native = broker(), + held = deferred(), + error = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + native.activate(); + if (kind === "snapshot") + vi.mocked(native.bridge.snapshot).mockReturnValueOnce(held.promise); + else vi.mocked(native.bridge.ack).mockReturnValueOnce(held.promise); + const start = transport.start(handoff, undefined, error); + const outcome = start.then( + () => "resolved", + () => "rejected", + ); + await turn(); + native.activate(); + await turn(); + expect(handoff).toHaveBeenCalledWith(target, 2); + held.reject(new Error("old startup failure")); + expect(await outcome).toBe("resolved"); + expect(error).not.toHaveBeenCalled(); + }, +); + +it.each(["snapshot", "ack"])( + "still reports the genuine current background %s failure", + async (kind) => { + const native = broker(), + error = vi.fn(), + handoff = vi.fn((_target: NoteTarget, _sequence: number) => true), + transport = createNativeNoteLinkIngress(native.bridge); + await transport.start(handoff, undefined, error); + if (kind === "snapshot") + vi.mocked(native.bridge.snapshot).mockRejectedValueOnce( + new Error("current failure"), + ); + else + vi.mocked(native.bridge.ack).mockRejectedValueOnce( + new Error("current failure"), + ); + native.activate(); + await turn(); + expect(error).toHaveBeenCalledExactlyOnceWith(); + expect(handoff).toHaveBeenCalledTimes(kind === "ack" ? 1 : 0); + }, +); +it.each(["snapshot", "ack"])( + "still rejects a genuine current startup %s failure", + async (kind) => { + const native = broker(), + transport = createNativeNoteLinkIngress(native.bridge); + native.activate(); + if (kind === "snapshot") + vi.mocked(native.bridge.snapshot).mockRejectedValueOnce( + new Error("current startup failure"), + ); + else + vi.mocked(native.bridge.ack).mockRejectedValueOnce( + new Error("current startup failure"), + ); + await expect(transport.start(() => true)).rejects.toThrow( + "current startup failure", + ); + }, +); + +it("native bridge subscribes explicitly to the same main WebviewWindow target as Rust", async () => { + const unlisten = vi.fn(); + nativeWiring.listen.mockResolvedValue(unlisten); + nativeWiring.invoke.mockImplementation(async (command: string) => + command === "note_link_retire" + ? { sequence: 1 } + : { sequence: 0, pending: null }, + ); + await nativeNoteLinkIngress.start(() => true); + expect(nativeWiring.listen).toHaveBeenCalledWith( + "notes-reference-pending", + expect.any(Function), + { target: { kind: "WebviewWindow", label: "main" } }, + ); + expect(nativeWiring.invoke).toHaveBeenCalledWith("note_link_snapshot"); + await nativeNoteLinkIngress.stop(); + expect(unlisten).toHaveBeenCalledOnce(); +}); diff --git a/web/tests/ui/desktop.test.tsx b/web/tests/ui/desktop.test.tsx index c805ba6..b4511ef 100644 --- a/web/tests/ui/desktop.test.tsx +++ b/web/tests/ui/desktop.test.tsx @@ -46,9 +46,16 @@ describe("desktop window", () => { it("uses native controls and updates maximize state from the window", async () => { vi.mocked(isDesktop).mockReturnValue(true); const window = windowMock(); let resized!: () => void; - vi.mocked(connectWindow).mockImplementation(async (_close, resize) => { resized = resize; return window; }); + const pending = deferred(); + vi.mocked(connectWindow).mockImplementation((_close, resize) => { resized = resize; return pending.promise; }); render(Content); - fireEvent.click(await screen.findByRole("button", { name: "Minimize window" })); + const minimize = screen.getByRole("button", { name: "Minimize window" }); + expect(minimize).toBeDisabled(); + fireEvent.click(minimize); + expect(window.minimize).not.toHaveBeenCalled(); + await act(async () => pending.resolve(window)); + await waitFor(() => expect(minimize).toBeEnabled()); + fireEvent.click(minimize); fireEvent.click(screen.getByRole("button", { name: "Maximize window" })); fireEvent.click(screen.getByRole("button", { name: "Close window" })); expect(window.minimize).toHaveBeenCalledOnce(); expect(window.toggleMaximize).toHaveBeenCalledOnce(); expect(window.close).toHaveBeenCalledOnce(); diff --git a/web/tests/ui/external-url.test.ts b/web/tests/ui/external-url.test.ts new file mode 100644 index 0000000..eacd40c --- /dev/null +++ b/web/tests/ui/external-url.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { openExternalUrl } from "../../src/desktop/external"; +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), isTauri: vi.fn() })); +beforeEach(() => { + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockReset(); +}); +it("hands off exactly one explicit valid URL using the restricted native command", async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + const url = "https://example.test/source?q=one#passage"; + await openExternalUrl(url); + expect(invoke).toHaveBeenCalledExactlyOnceWith("open_external_url", { url }); +}); +it.each([ + "javascript:alert(1)", + "file:///C:/Windows/win.ini", + "mailto:synthetic@example.test", + "//example.test", + "https:example.test", + "https:///example.test", + "https://@example.test", + "https://user:synthetic@example.test/", + "https://example.test/a b", + "https://example.test/\n", + "https://example.test/\0", + String.raw`https://example.test\other`, + "https://example.test/" + "a".repeat(8192), +])( + "rejects an unsafe destination before invoking native APIs: %s", + async (url) => { + await expect(openExternalUrl(url)).rejects.toThrow("Copy its URL"); + expect(invoke).not.toHaveBeenCalled(); + }, +); +it("reports bridge failure without leaking the URI or platform error", async () => { + vi.mocked(invoke).mockRejectedValue( + new Error("Synthetic internal path and URI"), + ); + await expect(openExternalUrl("https://example.test/")).rejects.toThrow( + "Copy its URL", + ); +}); +it("does not simulate native opening in a browser", async () => { + vi.mocked(isTauri).mockReturnValue(false); + await expect(openExternalUrl("https://example.test/")).rejects.toThrow( + "Copy its URL", + ); + expect(invoke).not.toHaveBeenCalled(); +});