Open rad: links in the app

This commit is contained in:
Rūdolfs Ošiņš
2026-10-05 17:37:03 +02:00
parent 91ab693b4b
commit 2f283f1047
13 changed files with 902 additions and 15 deletions
Generated
+480
View File
@@ -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",
]
+2
View File
@@ -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"] }
@@ -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"
+48 -12
View File
@@ -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
}
+7
View File
@@ -69,5 +69,12 @@
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["rad"]
}
}
}
}
+2 -1
View File
@@ -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
+10
View File
@@ -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",
+1
View File
@@ -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",
+3 -1
View File
@@ -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,
+4 -1
View File
@@ -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;
+127
View File
@@ -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<void>) {
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;
}
}
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { hide } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
interface Props {
url: string;
host: string;
}
const { url, host }: Props = $props();
async function open() {
const { open } = await import("@tauri-apps/plugin-shell");
await open(url);
hide();
}
</script>
<style>
.modal {
width: 26rem;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-lg);
background-color: var(--color-surface-canvas);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 3.25rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.title {
font: var(--txt-heading-s);
color: var(--color-text-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.url {
font: var(--txt-code-regular);
color: var(--color-text-tertiary);
word-break: break-all;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1.5rem 1.5rem;
}
.confirm-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
</style>
<div class="modal">
<div class="header">
<span class="title">Open in browser</span>
<Button variant="naked" onclick={hide}>
<span style:color="var(--color-text-tertiary)">
<Icon name="close" />
</span>
</Button>
</div>
<div class="body">
<span>
A link was opened for something that isn't on this node. It can be viewed
on {host} instead.
</span>
<span class="url">{url}</span>
</div>
<div class="actions">
<Button variant="outline" onclick={hide}>Cancel</Button>
<Button variant="ghost" onclick={() => void open()}>
<span class="confirm-label">
<Icon name="open-external" />
Open in {host}
</span>
</Button>
</div>
</div>
+120
View File
@@ -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);
});
});