From 2f283f1047e76e83d878a317b1539cf2c20e4443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Mon, 5 Oct 2026 14:36:08 +0200 Subject: [PATCH] Open rad: links in the app --- Cargo.lock | 480 ++++++++++++++++++ crates/radicle-tauri/Cargo.toml | 2 + .../radicle-tauri/capabilities/default.json | 1 + crates/radicle-tauri/src/lib.rs | 60 ++- crates/radicle-tauri/tauri.conf.json | 7 + docs/adr/0003-radicle-references.md | 3 +- package-lock.json | 10 + package.json | 1 + src/App.svelte | 4 +- src/lib/auth.svelte.ts | 5 +- src/lib/deepLink.ts | 127 +++++ src/modals/OpenInBrowser.svelte | 97 ++++ tests/unit/deepLink.test.ts | 120 +++++ 13 files changed, 902 insertions(+), 15 deletions(-) create mode 100644 src/lib/deepLink.ts create mode 100644 src/modals/OpenInBrowser.svelte create mode 100644 tests/unit/deepLink.test.ts diff --git a/Cargo.lock b/Cargo.lock index 597bc5c26..e6ae2b11e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,137 @@ dependencies = [ "syn 2.0.119", ] +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +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.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +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.2", +] + +[[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.2", +] + [[package]] name = "atk" version = "0.18.2" @@ -474,6 +605,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 = "blowfish" version = "0.9.1" @@ -809,12 +953,41 @@ 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 = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[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 = "const-str" version = "0.4.3" @@ -1311,6 +1484,15 @@ dependencies = [ "syn 2.0.119", ] +[[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 = "dom_query" version = "0.27.0" @@ -1554,6 +1736,33 @@ dependencies = [ "cfg-if", ] +[[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 = "env_filter" version = "0.1.4" @@ -1597,6 +1806,26 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[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 = "fast-glob" version = "0.3.3" @@ -1777,6 +2006,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[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.33" @@ -2211,6 +2453,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" @@ -3359,6 +3607,26 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3432,6 +3700,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" @@ -3591,6 +3865,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 = "pkcs1" version = "0.7.5" @@ -3657,6 +3942,20 @@ dependencies = [ "miniz_oxide", ] +[[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 = "poly1305" version = "0.8.0" @@ -4043,9 +4342,11 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-window-state", "thiserror 2.0.19", "tokio", @@ -4276,6 +4577,16 @@ dependencies = [ "zeroize", ] +[[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.3" @@ -5436,6 +5747,27 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.2" @@ -5520,6 +5852,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.19", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -5791,6 +6140,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.3" @@ -6807,6 +7165,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-result" version = "0.2.0" @@ -7284,6 +7653,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.4", + "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.2", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -7387,3 +7826,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.2", + "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.2", + "winnow 1.0.4", +] diff --git a/crates/radicle-tauri/Cargo.toml b/crates/radicle-tauri/Cargo.toml index 75d43cc2c..a178f8a0f 100644 --- a/crates/radicle-tauri/Cargo.toml +++ b/crates/radicle-tauri/Cargo.toml @@ -27,9 +27,11 @@ serde = { version = "1.0.0", features = ["derive"] } serde_json = { version = "1.0.0" } tauri = { version = "2.11.1", features = ["isolation"] } tauri-plugin-clipboard-manager = { version = "2.3.2" } +tauri-plugin-deep-link = { version = "2.4.10" } tauri-plugin-dialog = { version = "2.7.0" } tauri-plugin-log = { version = "2.8.0" } tauri-plugin-shell = { version = "2.3.5" } +tauri-plugin-single-instance = { version = "2.4.5", features = ["deep-link"] } tauri-plugin-window-state = { version = "2.4.1" } thiserror = { version = "2.0.12" } tokio = { version = "1.45.0", features = ["time"] } diff --git a/crates/radicle-tauri/capabilities/default.json b/crates/radicle-tauri/capabilities/default.json index d5a6e2cbf..5b2a7eaca 100644 --- a/crates/radicle-tauri/capabilities/default.json +++ b/crates/radicle-tauri/capabilities/default.json @@ -17,6 +17,7 @@ "core:window:allow-set-badge-count", "core:window:allow-start-dragging", "core:window:default", + "deep-link:default", "dialog:default", "log:default", "shell:allow-open" diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 27232cb94..747a09348 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -1,6 +1,8 @@ mod commands; use radicle_types::AppState; +use tauri::Manager; +use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_log::{Target, TargetKind}; use commands::{auth, cob, diff, identity, inbox, profile, repo, startup, thread}; @@ -22,24 +24,48 @@ pub fn run() { // survives the pipes that `npm run tauri dev` puts between the app and the // terminal, which `std::io::IsTerminal` on our own stdout would not. let terminal = std::env::var_os("TERM").is_some(); - let builder = tauri::Builder::default().plugin( - tauri_plugin_log::Builder::new() - .level(log::LevelFilter::Info) - .max_file_size(5_000_000) - .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne) - .target(Target::new(if terminal { - TargetKind::Stdout - } else { - TargetKind::LogDir { file_name: None } - })) - .build(), - ); + // A `rad:` link opened while the app runs starts a second instance, which + // hands the link to this one and exits; the deep-link plugin delivers it. + let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.set_focus(); + } + })) + .plugin( + tauri_plugin_log::Builder::new() + .level(log::LevelFilter::Info) + .max_file_size(5_000_000) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne) + .target(Target::new(if terminal { + TargetKind::Stdout + } else { + TargetKind::LogDir { file_name: None } + })) + .build(), + ); builder .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(tauri_plugin_deep_link::init()) + .setup(|app| { + // Installers register the scheme; an AppImage or a dev build has to + // do it at runtime. It must never keep the app from starting, nor + // take the scheme back from a handler the user chose. + let unpackaged = cfg!(debug_assertions) || is_appimage(app); + if cfg!(any(target_os = "linux", windows)) + && unpackaged + && !app.deep_link().is_registered("rad").unwrap_or(false) + && let Err(e) = app.deep_link().register_all() + { + log::warn!("Unable to register the rad: scheme: {e}"); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ auth::authenticate, auth::init, @@ -105,3 +131,13 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(target_os = "linux")] +fn is_appimage(app: &tauri::App) -> bool { + app.env().appimage.is_some() +} + +#[cfg(not(target_os = "linux"))] +fn is_appimage(_app: &tauri::App) -> bool { + false +} diff --git a/crates/radicle-tauri/tauri.conf.json b/crates/radicle-tauri/tauri.conf.json index a15ee1216..5bbf6effd 100644 --- a/crates/radicle-tauri/tauri.conf.json +++ b/crates/radicle-tauri/tauri.conf.json @@ -69,5 +69,12 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["rad"] + } + } } } diff --git a/docs/adr/0003-radicle-references.md b/docs/adr/0003-radicle-references.md index a77d8b8ab..66aeaf311 100644 --- a/docs/adr/0003-radicle-references.md +++ b/docs/adr/0003-radicle-references.md @@ -59,7 +59,8 @@ labelled with the repo name, such as `heartwood: src/lib.rs`. Either navigates in-app when the repo is local and falls back to the configured `publicExplorer`, which a right click also offers. A resource a client does not support renders as a plain link. Sanitizers allow `rad:` and `did:` -hrefs, which are inert without a registered handler. +hrefs, which cannot execute; this app also registers as the OS `rad:` +handler, so a link clicked elsewhere opens in it. ## Consequences diff --git a/package-lock.json b/package-lock.json index ff2e2a350..b309c69c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@pierre/trees": "1.0.0-beta.5", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-shell": "^2.3.5", @@ -1267,6 +1268,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.10.tgz", + "integrity": "sha512-SuXI47ORUzYuwG6c8dluLDrStTvli5bAZDRa2+VuUEzK/9TEa7FZz7UKXBQriL7+CPFXRHQsz5tSocc47tRXQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", diff --git a/package.json b/package.json index 6ed358f8b..a0fd04d8d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@pierre/trees": "1.0.0-beta.5", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-shell": "^2.3.5", diff --git a/src/App.svelte b/src/App.svelte index a35b9abbc..e0b772518 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ resetFontSize, } from "@app/lib/appearance.svelte"; import { checkAuth, startup } from "@app/lib/auth.svelte"; + import { deepLinksReady, listenForDeepLinks } from "@app/lib/deepLink"; import { nodeRunning } from "@app/lib/events"; import { dynamicInterval } from "@app/lib/interval"; import { invoke } from "@app/lib/invoke"; @@ -197,9 +198,10 @@ setUnlistenNodeEvents(() => clearInterval(interval)); } + void listenForDeepLinks(); try { await invoke("authenticate"); - void router.loadFromLocation(); + void router.loadFromLocation().then(deepLinksReady); dynamicInterval( "auth", checkAuth, diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index 5aeb4a066..b192087bc 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -2,6 +2,7 @@ import type { ErrorWrapper } from "@bindings/error/ErrorWrapper"; import { get } from "svelte/store"; +import { deepLinksReady } from "@app/lib/deepLink"; import { dynamicInterval } from "@app/lib/interval"; import { invoke } from "@app/lib/invoke"; import * as router from "@app/lib/router"; @@ -23,7 +24,9 @@ export async function checkAuth() { import.meta.env.VITE_AUTH_LONG_DELAY || 30_000, ); if (get(router.activeRouteStore).resource === "booting") { - void router.push({ resource: "inbox" }); + void router.push({ resource: "inbox" }).then(deepLinksReady); + } else { + deepLinksReady(); } } catch (err) { const error = err as ErrorWrapper; diff --git a/src/lib/deepLink.ts b/src/lib/deepLink.ts new file mode 100644 index 000000000..014a2904c --- /dev/null +++ b/src/lib/deepLink.ts @@ -0,0 +1,127 @@ +import type { RepoRoute } from "@app/views/repo/router"; +import type { Config } from "@bindings/config/Config"; + +import { cachedConfig, cachedRepoById } from "@app/lib/invoke"; +import { referenceRoute } from "@app/lib/mentions"; +import { show } from "@app/lib/modal"; +import type { RadReference, RadUri } from "@app/lib/radUri"; +import { parseRadUri } from "@app/lib/radUri"; +import * as router from "@app/lib/router"; +import { explorerHost, explorerLink } from "@app/lib/utils"; + +import OpenInBrowser from "@app/modals/OpenInBrowser.svelte"; + +export type DeepLinkTarget = + { type: "route"; route: RepoRoute } | { type: "external"; url: string }; + +export function parseDeepLink( + link: string, +): { type: "uri"; uri: RadUri } | undefined { + const uri = parseRadUri(link.trim()); + + return uri && { type: "uri", uri }; +} + +export function deepLinkTarget( + reference: RadReference, + local: boolean, + config: Config, +): DeepLinkTarget | undefined { + const route = local ? referenceRoute(reference) : undefined; + if (route) return { type: "route", route }; + + const url = explorerLink(reference, config); + + return url ? { type: "external", url } : undefined; +} + +// Links can arrive before the app has authenticated and routed, so they wait +// until it has, and are opened one at a time. +export function createLinkQueue(open: (link: string) => Promise) { + const pending: string[] = []; + let ready = false; + let flushing = false; + + async function flush() { + if (!ready || flushing) return; + flushing = true; + try { + for (let link = pending.shift(); link; link = pending.shift()) { + await open(link).catch(console.error); + } + } finally { + flushing = false; + } + } + + return { + receive(links: string[]) { + pending.push(...links); + void flush(); + }, + ready() { + ready = true; + void flush(); + }, + }; +} + +// An untrusted link only ever navigates; it never fetches or seeds, and +// leaving the app for the browser needs the user's confirmation. +async function openDeepLink(link: string) { + const reference = parseDeepLink(link); + if (!reference) return; + + const [config, repo] = await Promise.all([ + cachedConfig(), + cachedRepoById(`rad:${reference.uri.repo}`).catch(() => undefined), + ]); + const target = deepLinkTarget(reference, Boolean(repo), config); + if (target?.type === "route") { + await router.push(target.route); + } else if (target?.type === "external") { + show({ + component: OpenInBrowser, + props: { url: target.url, host: explorerHost(config) }, + }); + } +} + +const queue = createLinkQueue(openDeepLink); +const launchKey = "deepLink:launch"; + +export function deepLinksReady() { + queue.ready(); +} + +export async function listenForDeepLinks() { + if (!window.__TAURI_INTERNALS__) return; + + const { getCurrent, onOpenUrl } = + await import("@tauri-apps/plugin-deep-link"); + await onOpenUrl(links => queue.receive(links)); + + // A reload of the webview reports the same launch link again. + const launched = (await getCurrent()) ?? []; + const seen = readLaunch(); + if (launched.length > 0 && JSON.stringify(launched) !== seen) { + writeLaunch(JSON.stringify(launched)); + queue.receive(launched); + } +} + +function readLaunch(): string | null { + try { + return sessionStorage.getItem(launchKey); + } catch { + return null; + } +} + +function writeLaunch(value: string) { + try { + sessionStorage.setItem(launchKey, value); + } catch { + return; + } +} diff --git a/src/modals/OpenInBrowser.svelte b/src/modals/OpenInBrowser.svelte new file mode 100644 index 000000000..23fe6d557 --- /dev/null +++ b/src/modals/OpenInBrowser.svelte @@ -0,0 +1,97 @@ + + + + + diff --git a/tests/unit/deepLink.test.ts b/tests/unit/deepLink.test.ts new file mode 100644 index 000000000..3f89211df --- /dev/null +++ b/tests/unit/deepLink.test.ts @@ -0,0 +1,120 @@ +import type { Config } from "@bindings/config/Config"; + +import { describe, expect, test, vi } from "vitest"; + +import { + createLinkQueue, + deepLinkTarget, + parseDeepLink, +} from "@app/lib/deepLink"; + +const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const rid = `rad:${repo}`; +const oid = "5e4683023476778201ab25c87e1343fbfbe67c89"; +const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"; +const config = { + publicExplorer: "https://radicle.network/nodes/$host/$rid$path", + preferredSeeds: [], +} as unknown as Config; +const explorer = `https://radicle.network/nodes/rosa.radicle.network/${rid}`; + +function target(link: string, local = true) { + return deepLinkTarget(parseDeepLink(link)!, local, config); +} + +describe("parseDeepLink", () => { + test.each([rid, `rad:///${repo}`, ` ${rid}\n`])("accepts %j", link => { + expect(parseDeepLink(link)).toEqual({ type: "uri", uri: { repo } }); + }); + + test.each([ + `did:key:${nid}`, + "https://radicle.network", + `web+${rid}`, + "rad:bogus", + ])("rejects %j", link => { + expect(parseDeepLink(link)).toBeUndefined(); + }); +}); + +describe("deepLinkTarget", () => { + test.each([ + [rid, { resource: "repo.home", rid }], + [ + `${rid}/cob/xyz.radicle.patch/${oid}`, + { + resource: "repo.patch", + rid, + patch: oid, + status: undefined, + reviewId: undefined, + }, + ], + [`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }], + [ + `${rid}/commit/main?path=src/lib.rs#L10`, + { resource: "repo.home", rid, revision: "main", path: "src/lib.rs" }, + ], + ])("opens %s in-app when the repo is here", (link, route) => { + expect(target(link)).toEqual({ type: "route", route }); + }); + + test("opens the explorer when the repo is not here", () => { + expect(target(`${rid}/cob/xyz.radicle.issue/${oid}`, false)).toEqual({ + type: "external", + url: `${explorer}/issues/${oid}`, + }); + }); + + test("opens the explorer for a page the app lacks", () => { + expect(target(`${rid}/cob/dev.radicle.artifact/${oid}`)).toEqual({ + type: "external", + url: `${explorer}/releases/${oid}`, + }); + }); + + test("goes nowhere when neither has a page", () => { + expect(target(`${rid}/tree/${oid}`)).toBeUndefined(); + }); +}); + +describe("createLinkQueue", () => { + function setup(fail?: string) { + const opened: string[] = []; + let running = 0; + let overlapped = false; + const queue = createLinkQueue(async link => { + running++; + overlapped ||= running > 1; + await new Promise(resolve => setTimeout(resolve, 1)); + running--; + if (link === fail) throw new Error(link); + opened.push(link); + }); + return { queue, opened, overlapped: () => overlapped }; + } + const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + + test("holds links until the app is ready", async () => { + const { queue, opened } = setup(); + queue.receive(["a"]); + await settle(); + expect(opened).toEqual([]); + + queue.ready(); + queue.receive(["b"]); + await settle(); + expect(opened).toEqual(["a", "b"]); + }); + + test("opens links one at a time, past a failing one", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const { queue, opened, overlapped } = setup("b"); + queue.ready(); + queue.receive(["a", "b"]); + queue.receive(["c"]); + await settle(); + expect(opened).toEqual(["a", "c"]); + expect(overlapped()).toBe(false); + }); +});