feat(macos): add deep link support for qobuzapp:// URLs

Add tauri-plugin-deep-link to handle qobuzapp:// URLs on macOS, where
the OS delivers URLs via Apple Events instead of CLI arguments.

- Register qobuzapp scheme in tauri.conf.json (injected into Info.plist)
- Add on_open_url handler for Apple Event URL delivery
- Extract is_qobuz_link() and handle_qobuz_link() helpers to deduplicate
  URL matching across single-instance, first-launch, and deep-link paths
- Tighten URL matching from contains() to starts_with() for security
- Log emit errors instead of silently discarding them
This commit is contained in:
Afonso Jorge Ramos committed 2026-03-31 05:53:44 +02:00
1 parent 0ce54c3f10
commit a2fb44511d
5 files changed
+173 -25

No files matched your search

+11
View File
@@ -195,6 +195,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -238,6 +239,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1161,6 +1163,7 @@
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1203,6 +1206,7 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -1723,6 +1727,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2304,6 +2309,7 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -2522,6 +2528,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2742,6 +2749,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
"integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2969,6 +2977,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2983,6 +2992,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -3078,6 +3088,7 @@
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.0",
"@vitest/mocker": "4.1.0",
+93 -1
View File
@@ -929,6 +929,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -1438,6 +1458,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -2596,7 +2625,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry",
"windows-registry 0.6.1",
]
[[package]]
@@ -3880,6 +3909,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -4654,6 +4693,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
@@ -5306,6 +5346,16 @@ dependencies = [
"smallvec",
]
[[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 = "rust_cast"
version = "0.18.1"
@@ -6469,6 +6519,27 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
@@ -6540,6 +6611,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
@@ -6767,6 +6839,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 = "tiny_http"
version = "0.12.0"
@@ -7936,6 +8017,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
+2 -1
View File
@@ -34,7 +34,8 @@ qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
# Tauri
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "macos-private-api"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-deep-link = "2"
# Serialization
serde = { version = "1", features = ["derive"] }
+60 -23
View File
@@ -421,6 +421,42 @@ fn should_use_main_window_transparency() -> bool {
.unwrap_or(false)
}
/// Check if a string looks like a Qobuz link (custom scheme or web URL).
fn is_qobuz_link(s: &str) -> bool {
s.starts_with("qobuzapp://")
|| s.starts_with("https://play.qobuz.com/")
|| s.starts_with("http://play.qobuz.com/")
|| s.starts_with("https://open.qobuz.com/")
|| s.starts_with("http://open.qobuz.com/")
}
/// Resolve a Qobuz link and emit the result to the frontend.
/// If `delay` is true, waits 1500ms to give the frontend time to mount (first launch).
fn handle_qobuz_link(handle: &tauri::AppHandle, url: &str, delay: bool) {
if !is_qobuz_link(url) {
return;
}
match qbz_qobuz::resolve_link(url) {
Ok(resolved) => {
log::info!("[Link] Resolved: {:?}", resolved);
if delay {
let h = handle.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
if let Err(e) = h.emit("link:resolved", &resolved) {
log::error!("[Link] Failed to emit resolved link: {}", e);
}
});
} else if let Err(e) = handle.emit("link:resolved", &resolved) {
log::error!("[Link] Failed to emit resolved link: {}", e);
}
}
Err(e) => {
log::debug!("[Link] Failed to resolve '{}': {}", url.split('?').next().unwrap_or(url), e);
}
}
}
pub fn run() {
// Load .env file if present (for development)
// Silently ignore if not found (production builds use compile-time env vars)
@@ -693,18 +729,13 @@ pub fn run() {
// Check if second instance was launched with a Qobuz link arg
for arg in &args {
if arg.starts_with("qobuzapp://")
|| arg.contains("play.qobuz.com/")
|| arg.contains("open.qobuz.com/")
{
if let Ok(resolved) = qbz_qobuz::resolve_link(arg) {
log::info!("Single-instance forwarding link: {:?}", resolved);
let _ = app.emit("link:resolved", &resolved);
}
if is_qobuz_link(arg) {
handle_qobuz_link(app, arg, false);
break;
}
}
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -856,30 +887,36 @@ pub fn run() {
// NOTE: Subscription purge check moved to activate_user_session
// (runs after login when per-user state is available)
// Check if app was launched with a Qobuz link argument
// Check if app was launched with a Qobuz link argument.
// (first launch, not single-instance — that's handled by the plugin above)
// Note: on macOS, URLs arrive via Apple Events (deep-link handler below),
// not CLI args, so this path is only active on Linux/Windows.
{
let launch_handle = app.handle().clone();
let args: Vec<String> = std::env::args().collect();
for arg in &args[1..] {
// skip binary name
if arg.starts_with("qobuzapp://")
|| arg.contains("play.qobuz.com/")
|| arg.contains("open.qobuz.com/")
{
if let Ok(resolved) = qbz_qobuz::resolve_link(arg) {
log::info!("Launch arg link resolved: {:?}", resolved);
// Delay emission to give frontend time to mount
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
let _ = launch_handle.emit("link:resolved", &resolved);
});
}
if is_qobuz_link(arg) {
handle_qobuz_link(app.handle(), arg, true);
break;
}
}
}
// Register deep link handler for qobuzapp:// URLs.
// On macOS, URLs are delivered via Apple Events (not CLI args), so
// this is the only way to receive them. On Linux/Windows, the
// single-instance plugin forwards URL args to this handler too.
{
use tauri_plugin_deep_link::DeepLinkExt;
let deep_link_handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
let url_str = url.as_str();
log::debug!("[Deep Link] Received URL: {}", url_str.split('?').next().unwrap_or(url_str));
handle_qobuz_link(&deep_link_handle, url_str, false);
}
});
}
// Start background task to emit playback events
let app_handle = app.handle().clone();
let legacy_player_state = app.state::<AppState>().player.state.clone();
+7
View File
@@ -31,6 +31,13 @@
}
}
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["qobuzapp"]
}
}
},
"bundle": {
"active": true,
"targets": "all",