Files
qbz/crates/qbz-qt/src/deep_link_qt.rs
T
vicrodh 7ea01a7e67 fix(startup): bound and cancel the Linux instance probe
Use one two-second deadline across native Unix connect, D-Bus authentication, name ownership and URL/window forwarding. Own and shut down the transport on cancellation, preserve unconfirmed links, and keep the primary server alive.

Cover stalled and delayed phases, filesystem/abstract buses, ownership release and early UI handoff with isolated fixtures. All 733 Qt tests pass; async-io features remain unchanged.

cx
2026-09-06 05:05:27 -06:00

207 lines
7.4 KiB
Rust

//! Launcher deep links shared by cold starts and the Linux single-instance
//! D-Bus handoff.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
static PENDING: Mutex<Option<String>> = Mutex::new(None);
static ONLINE_SESSION: AtomicBool = AtomicBool::new(false);
/// The native Qobuz URL shapes accepted by the desktop launcher files.
pub(crate) fn is_qobuz_link(arg: &str) -> bool {
arg.starts_with("qobuzapp://")
|| arg.starts_with("https://play.qobuz.com/")
|| arg.starts_with("http://play.qobuz.com/")
|| arg.starts_with("https://open.qobuz.com/")
|| arg.starts_with("http://open.qobuz.com/")
}
/// `qbz://` is OUR scheme, registered by the Windows MSI unconditionally
/// (`qobuzapp://` is a runtime opt-in there — link_handler_qt). It is rewritten rather than taught downstream: the resolver
/// (`qbz-music-link`) knows `qobuzapp://` and the Qobuz web shapes and nothing
/// else, so an accepted `qbz://` would travel the whole way and then be
/// classified "unsupported or invalid music link" -- which is exactly what a
/// live test of the forwarding path produced. The two carry the same
/// `album/track/artist` shape, so a prefix swap is the whole translation.
///
/// `is_qobuz_link` deliberately still answers false for it: that predicate
/// describes what the LAUNCHER files advertise, and its test pins that.
fn rewrite_own_scheme(arg: &str) -> Option<String> {
arg.strip_prefix("qbz://")
.map(|rest| format!("qobuzapp://{rest}"))
}
/// The first argument that is a link we can actually act on.
///
/// Keeps SCANNING past one it cannot use rather than giving up: a launcher can
/// hand over several arguments and the first match is not necessarily the
/// usable one.
fn select_link(args: &[String]) -> Option<String> {
for arg in args {
let candidate = if is_qobuz_link(arg) {
arg.clone()
} else if let Some(rewritten) = rewrite_own_scheme(arg) {
rewritten
} else {
continue;
};
// WINDOWS ONLY. ShellExecute hands the app whatever the registered
// `shell\open\command` produced, and an unencoded space in the link
// splits it across argv -- so a fragment can carry the scheme and
// nothing else. Acting on that navigates nowhere and reads as the deep
// link silently failing.
//
// Not applied elsewhere: Linux and macOS have accepted whatever
// matched the prefix since this shipped, and tightening that from a
// Windows port would be a behaviour change on platforms that never
// asked for it.
#[cfg(target_os = "windows")]
if url::Url::parse(&candidate).is_err() {
log::warn!(
"[qbz-qt] deep link: ignoring an unparseable argument ({})",
candidate.split('?').next().unwrap_or(&candidate)
);
continue;
}
return Some(candidate);
}
None
}
/// Is this something the forwarding path may act on?
///
/// The single-instance server feeds it whatever arrived on the pipe, which is
/// not argv and is not ours.
#[cfg(any(target_os = "windows", test))]
pub(crate) fn is_actionable(url: &str) -> bool {
if !is_qobuz_link(url) && !url.starts_with("qbz://") {
return false;
}
// A parse is NOT enough, which a test caught: `Url::parse("qbz://")`
// SUCCEEDS -- a valid scheme with an empty everything -- so a bare scheme
// would have passed the gate and reached the resolver as a navigation
// request for nothing.
//
// Every shape this accepts carries its target in the AUTHORITY:
// `qobuzapp://album/1` and `qbz://album/1` put "album" there, and the web
// URLs put the Qobuz host there. An empty one is not a link.
match url::Url::parse(url) {
Ok(u) => u.host_str().is_some_and(|h| !h.is_empty()),
Err(_) => false,
}
}
/// Capture argv before the single-instance decision so a duplicate process
/// can forward its URL to the owner of the bus name.
pub(crate) fn capture_argv() {
let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(url) = select_link(&args) {
log::info!(
"[qbz-qt] deep link: captured {}",
url.split('?').next().unwrap_or(&url)
);
stash(url);
}
}
/// Newest launch intent wins, matching the former frontend.
pub(crate) fn stash(url: String) {
if let Ok(mut pending) = PENDING.lock() {
*pending = Some(url);
}
}
/// Return an unconfirmed handoff without replacing a newer launch intent.
#[cfg(target_os = "linux")]
pub(crate) fn restore_pending(url: String) {
if let Ok(mut pending) = PENDING.lock() {
pending.get_or_insert(url);
}
}
pub(crate) fn take_pending() -> Option<String> {
PENDING.lock().ok().and_then(|mut pending| pending.take())
}
/// Bind/unbind the only context in which a Qobuz route can be fetched.
pub(crate) fn set_online_session(active: bool) {
ONLINE_SESSION.store(active, Ordering::SeqCst);
if active {
drain_pending();
}
}
/// Resolve the pending link once an authenticated shell is available.
pub(crate) fn drain_pending() {
if !ONLINE_SESSION.load(Ordering::SeqCst) {
return;
}
let Some(url) = take_pending() else {
return;
};
log::info!(
"[qbz-qt] deep link: resolving {}",
url.split('?').next().unwrap_or(&url)
);
crate::link_resolver_qt::resolve_deep_link(url);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_only_launcher_qobuz_shapes() {
assert!(is_qobuz_link("https://open.qobuz.com/album/a"));
assert!(is_qobuz_link("http://play.qobuz.com/track/1"));
assert!(is_qobuz_link("qobuzapp://artist/1"));
assert!(!is_qobuz_link("https://open.qobuz.com.evil.test/album/a"));
assert!(!is_qobuz_link("https://spotify.com/track/1"));
assert!(!is_qobuz_link("qbz://album/a"));
}
#[test]
fn our_own_scheme_is_rewritten_not_taught() {
// The resolver knows qobuzapp:// and the Qobuz web shapes. A qbz://
// URL that reached it came back "unsupported or invalid music link",
// so the translation happens here.
assert_eq!(
rewrite_own_scheme("qbz://album/12345").as_deref(),
Some("qobuzapp://album/12345")
);
assert_eq!(rewrite_own_scheme("qobuzapp://album/1"), None);
assert_eq!(rewrite_own_scheme("https://open.qobuz.com/album/1"), None);
}
#[test]
fn a_rewritten_link_is_selected() {
let args = vec!["--flag".to_string(), "qbz://track/9".to_string()];
assert_eq!(select_link(&args).as_deref(), Some("qobuzapp://track/9"));
}
#[test]
fn the_pipe_gate_accepts_only_real_links() {
assert!(is_actionable("qobuzapp://album/1"));
assert!(is_actionable("qbz://album/1"));
assert!(is_actionable("https://play.qobuz.com/album/1"));
assert!(!is_actionable("https://spotify.com/track/1"));
assert!(!is_actionable("qbz://"));
assert!(!is_actionable("not a url at all"));
}
#[test]
fn first_matching_argument_wins() {
let args = vec![
"--flag".to_string(),
"https://open.qobuz.com/album/first".to_string(),
"qobuzapp://album/second".to_string(),
];
assert_eq!(
select_link(&args),
Some("https://open.qobuz.com/album/first".to_string())
);
}
}