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:
5 files changed
+173
-25
No files matched your search
Generated
+11
@@ -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",
|
||||
|
||||
Generated
+93
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["qobuzapp"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
Reference in new issue
Block a user