feat(windows): single instance and protocol deep links
The Windows sibling of single_instance_qt.rs. Same three entry points and the
same present-or-defer behaviour, so main.rs reads identically on both.
A named mutex arbitrates and a named pipe carries the message, because those
are different problems. CreateMutexW answers "did it already exist" atomically,
with no window for a second launch to slip through. A pipe cannot be asked that
without FILE_FLAG_FIRST_PIPE_INSTANCE, and QLocalServer never passes it
(PIPE_UNLIMITED_INSTANCES), so two processes could both believe they were the
server.
The plan asked for QLocalServer here, which meant a new Q_OBJECT file in the
moc list and .qt_module("Network"). A raw named pipe is the same primitive
underneath with none of that build surface, for a protocol two verbs long.
The pipe name is QUALIFIED BY SESSION ID, and that is not cosmetic: the mutex
is `Local\`, which is per-session, while pipe names are machine-global. With
one fixed name and two users signed in, each takes its own mutex but only the
first creates the pipe -- the second primary then serves nothing and ITS later
launches would forward to the OTHER USER'S process. PIPE_REJECT_REMOTE_CLIENTS
because the namespace is reachable over SMB and nothing about a second launch
is remote.
What arrives on the pipe is VALIDATED before it is acted on. It is not argv:
any process running as this user can write a line, and the payload otherwise
went straight to link resolution. The read is bounded at 4 KB for the same
reason.
`qbz://` is rewritten to `qobuzapp://` rather than taught downstream. The
resolver knows qobuzapp and the Qobuz web shapes and nothing else -- a live
test of the forwarding path came back "unsupported or invalid music link" for
a qbz:// URL, and a test in this file already pinned that is_qobuz_link
excludes it. The two carry the same album/track/artist shape, so a prefix swap
is the whole translation, and the MSI's registration finally means something.
A parse alone is not a gate: Url::parse("qbz://") SUCCEEDS, a valid scheme with
an empty everything, so the check requires a non-empty authority -- which is
where every accepted shape carries its target.
The URL validation in select_link is Windows-gated. ShellExecute can split a
link across argv there; Linux and macOS have accepted whatever matched the
prefix since this shipped, and tightening that from a Windows port would change
behaviour on platforms that never asked.
present_or_defer re-reads UI_READY after publishing PENDING_PRESENT. bind_ui
can flip the flag and run its own swap in between, leaving the pending bit set
with nobody to consume it and a second launch looking ignored.
Verified on the box: a second launch carrying a deep link exits 0 in about four
milliseconds, the primary logs the handoff and resolves the rewritten URL.
571 tests pass.
cc
This commit is contained in:
6 files changed
+428
-1
No files matched your search
Generated
+1
@@ -4581,6 +4581,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.22.0",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -30,6 +30,10 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
urlencoding = "2"
|
||||
# Deep links are validated by a real parse before they are acted on: a
|
||||
# launcher can split a URL across argv, and a bare scheme is not a URL.
|
||||
# Already in the workspace lock via qbz-models.
|
||||
url = "2"
|
||||
open = "5"
|
||||
dirs = "5"
|
||||
|
||||
@@ -188,6 +192,16 @@ winresource = "0.1"
|
||||
# in the workspace lock.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_UI_Shell",
|
||||
# Single instance: CreateMutexW arbitrates, a named pipe carries the
|
||||
# handoff, and both need the error codes to tell "already there" from
|
||||
# "failed".
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Pipes",
|
||||
# ProcessIdToSessionId: the pipe name is qualified by session, because
|
||||
# pipe names are machine-global while the mutex is per-session.
|
||||
"Win32_System_RemoteDesktop",
|
||||
"Win32_Storage_FileSystem",
|
||||
# RegGetValueW: is this install's AppUserModelID actually registered?
|
||||
# Without that, Windows silently drops every toast.
|
||||
"Win32_System_Registry",
|
||||
|
||||
@@ -16,8 +16,80 @@ pub(crate) fn is_qobuz_link(arg: &str) -> bool {
|
||||
|| arg.starts_with("http://open.qobuz.com/")
|
||||
}
|
||||
|
||||
/// `qbz://` is OUR scheme, registered by the Windows MSI beside
|
||||
/// `qobuzapp://`. 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> {
|
||||
args.iter().find(|arg| is_qobuz_link(arg)).cloned()
|
||||
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.
|
||||
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
|
||||
@@ -81,6 +153,35 @@ mod tests {
|
||||
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![
|
||||
|
||||
@@ -16,6 +16,8 @@ mod auth_qt;
|
||||
mod deep_link_qt;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod single_instance_qt;
|
||||
// The Windows sibling. Carries its own `#![cfg(target_os = "windows")]`.
|
||||
mod single_instance_win;
|
||||
// Per-domain QML bridge singletons (phase 23 — the QbzBridge God-object
|
||||
// split). THE PATTERN (phase-1, replicated per domain file):
|
||||
// 1. one #[cxx_qt::bridge] mod per file (crate root — cxx-qt-build
|
||||
@@ -3349,6 +3351,15 @@ fn main() {
|
||||
log::info!("[qbz-qt] another instance owns the session bus name; exiting");
|
||||
return;
|
||||
}
|
||||
// Same decision, different primitive: a per-session named mutex arbitrates
|
||||
// and a named pipe carries the handoff. `acquire_or_raise` has already
|
||||
// forwarded this launch's deep link (or a bare PRESENT) by the time it
|
||||
// answers false, so there is nothing left to do but leave.
|
||||
#[cfg(target_os = "windows")]
|
||||
if !single_instance_win::acquire_or_raise() {
|
||||
log::info!("[qbz-qt] another instance owns the mutex; forwarded and exiting");
|
||||
return;
|
||||
}
|
||||
// Link anchor for the hand-written QAbstractListModel. Its QML singleton
|
||||
// registration itself runs at QCoreApplication startup.
|
||||
local_tracks_model_qt::register_qml_model();
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Windows single instance, and the deep-link handoff that rides on it.
|
||||
//!
|
||||
//! The sibling of `single_instance_qt.rs`, which does the same job on Linux
|
||||
//! over the session bus. Same three entry points, same `present_or_defer`
|
||||
//! behaviour, so `main.rs` reads the same either way.
|
||||
//!
|
||||
//! ## Why a mutex AND a pipe, rather than one of them
|
||||
//!
|
||||
//! Arbitration and transport are different problems and Windows solves them
|
||||
//! with different objects.
|
||||
//!
|
||||
//! `CreateMutexW` arbitrates ATOMICALLY: the kernel either creates the object
|
||||
//! or tells you it already existed, with no window in between for a second
|
||||
//! launch to slip through. A named pipe cannot be asked that question without
|
||||
//! `FILE_FLAG_FIRST_PIPE_INSTANCE`, and a Qt `QLocalServer` never passes it
|
||||
//! (`qlocalserver_win.cpp` opens with `PIPE_UNLIMITED_INSTANCES`), so two
|
||||
//! processes can both believe they are the server.
|
||||
//!
|
||||
//! The pipe then carries the message. The plan called for QLocalServer here,
|
||||
//! which would have meant a new `Q_OBJECT` C++ file in the moc list and
|
||||
//! `.qt_module("Network")` in the build. A raw named pipe is the same
|
||||
//! primitive underneath with none of that build surface, and this module owns
|
||||
//! both halves of a protocol that is two verbs long.
|
||||
//!
|
||||
//! ## The race the retry exists for
|
||||
//!
|
||||
//! The primary creates the mutex BEFORE its pipe server is listening. A second
|
||||
//! launch inside that gap finds the mutex taken and the pipe absent, so it
|
||||
//! must WAIT for the pipe rather than conclude nothing is there -- otherwise
|
||||
//! the deep link that started it is dropped on the floor.
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `Local\` = per-session, which matches the per-user MSI: two users logged in
|
||||
/// at once each get their own QBZ, exactly as they each get their own D-Bus
|
||||
/// session on Linux.
|
||||
const MUTEX_NAME: &str = "Local\\com.blitzfc.qbz.singleton\0";
|
||||
|
||||
/// The pipe name, QUALIFIED BY SESSION ID.
|
||||
///
|
||||
/// The mutex is `Local\`, which is per-session. Pipe names are NOT: the
|
||||
/// `\\.\pipe\` namespace is machine-global and has no session prefix. With one
|
||||
/// fixed name and two users signed in at once, each acquires its own mutex
|
||||
/// (different namespace) but only the first can create the pipe -- the second
|
||||
/// primary then serves nothing, and ITS later launches would forward to the
|
||||
/// OTHER USER'S process. Qualifying by session id restores the pairing the
|
||||
/// mutex already had.
|
||||
fn pipe_name() -> String {
|
||||
format!("\\\\.\\pipe\\com.blitzfc.qbz.singleton.{}", session_id())
|
||||
}
|
||||
|
||||
/// This process's Terminal Services session. 0 on failure, which is still a
|
||||
/// consistent answer -- every instance that fails agrees on it.
|
||||
fn session_id() -> u32 {
|
||||
use windows_sys::Win32::System::RemoteDesktop::ProcessIdToSessionId;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||
|
||||
let mut id: u32 = 0;
|
||||
// SAFETY: `id` is a valid out-slot; the call only writes it.
|
||||
let ok = unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut id) };
|
||||
if ok == 0 {
|
||||
0
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Held for the process lifetime and deliberately never closed: releasing it
|
||||
/// would let a second instance start while this one is still running.
|
||||
static MUTEX: OnceLock<usize> = OnceLock::new();
|
||||
static UI_READY: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_PRESENT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Called once QbzTray has registered its Qt-thread hop, exactly as the Linux
|
||||
/// module is. Independent of whether the tray icon itself is enabled.
|
||||
pub(crate) fn bind_ui() {
|
||||
UI_READY.store(true, Ordering::SeqCst);
|
||||
if PENDING_PRESENT.swap(false, Ordering::SeqCst) {
|
||||
crate::tray_qt::present();
|
||||
}
|
||||
}
|
||||
|
||||
/// Present now if the UI can take it, or remember to present when it can.
|
||||
///
|
||||
/// A forwarded message can arrive before the QML shell exists; presenting into
|
||||
/// that gap does nothing and the user's second launch looks ignored.
|
||||
fn present_or_defer() {
|
||||
if UI_READY.load(Ordering::SeqCst) {
|
||||
crate::tray_qt::present();
|
||||
return;
|
||||
}
|
||||
PENDING_PRESENT.store(true, Ordering::SeqCst);
|
||||
// RE-CHECK, and this is not belt-and-braces. `bind_ui` can set UI_READY
|
||||
// and run its own swap in the window between the load above and the store
|
||||
// below it; the flag would then be set with nobody left to consume it, and
|
||||
// the user's second launch would look ignored. Reading UI_READY again
|
||||
// after publishing closes that order.
|
||||
if UI_READY.load(Ordering::SeqCst) && PENDING_PRESENT.swap(false, Ordering::SeqCst) {
|
||||
crate::tray_qt::present();
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this process should continue as the primary instance.
|
||||
pub(crate) fn acquire_or_raise() -> bool {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_ALREADY_EXISTS};
|
||||
use windows_sys::Win32::System::Threading::CreateMutexW;
|
||||
|
||||
let name: Vec<u16> = MUTEX_NAME.encode_utf16().collect();
|
||||
// SAFETY: NUL-terminated UTF-16 that outlives the call; a null security
|
||||
// descriptor asks for the default, which is what a per-session object
|
||||
// wants.
|
||||
let handle = unsafe { CreateMutexW(std::ptr::null(), 1, name.as_ptr()) };
|
||||
if handle.is_null() {
|
||||
// Cannot arbitrate. Continuing as primary is the safe failure: the
|
||||
// worst case is two windows, where bailing out would be no window.
|
||||
log::warn!("[qbz-qt] CreateMutexW failed; continuing as the primary instance");
|
||||
return true;
|
||||
}
|
||||
|
||||
// SAFETY: reads this thread's last-error, set by the call above.
|
||||
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
|
||||
forward_to_primary();
|
||||
return false;
|
||||
}
|
||||
|
||||
let _ = MUTEX.set(handle as usize);
|
||||
start_server();
|
||||
true
|
||||
}
|
||||
|
||||
/// Hand our argv to the instance that owns the mutex.
|
||||
///
|
||||
/// RETRIES, because the primary creates the mutex before it listens. Ten
|
||||
/// attempts at 100 ms covers the gap without making a genuinely absent server
|
||||
/// (a primary that died between the two) cost more than a second.
|
||||
fn forward_to_primary() {
|
||||
let line = match crate::deep_link_qt::take_pending() {
|
||||
Some(url) => format!("OPEN {url}\n"),
|
||||
None => "PRESENT\n".to_string(),
|
||||
};
|
||||
|
||||
for attempt in 0..10 {
|
||||
match std::fs::OpenOptions::new().write(true).open(pipe_name()) {
|
||||
Ok(mut pipe) => {
|
||||
if let Err(e) = pipe.write_all(line.as_bytes()) {
|
||||
log::warn!("[qbz-qt] forwarding to the primary instance failed: {e}");
|
||||
}
|
||||
let _ = pipe.flush();
|
||||
log::info!(
|
||||
"[qbz-qt] another instance owns the mutex; forwarded {}",
|
||||
line.split_whitespace().next().unwrap_or("?")
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(_) if attempt < 9 => std::thread::sleep(Duration::from_millis(100)),
|
||||
Err(e) => log::warn!(
|
||||
"[qbz-qt] the primary instance is not listening ({e}); exiting anyway"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the pipe for the life of the process.
|
||||
///
|
||||
/// One connection at a time is enough: the messages are two words long and a
|
||||
/// second launch is a human action. A fresh pipe instance is created per
|
||||
/// connection because `std::fs::File` closes its handle on drop, which
|
||||
/// disconnects the client cleanly.
|
||||
fn start_server() {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("qbz-single-instance".to_string())
|
||||
.spawn(|| loop {
|
||||
let Some(handle) = create_pipe_instance() else {
|
||||
// The name is unusable; without a server this process is still
|
||||
// a perfectly good primary, so stop serving rather than spin.
|
||||
log::warn!("[qbz-qt] single-instance pipe unavailable; not listening");
|
||||
return;
|
||||
};
|
||||
if let Some(line) = accept_one(handle) {
|
||||
handle_line(line.trim());
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("[qbz-qt] could not spawn the single-instance server: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn create_pipe_instance() -> Option<isize> {
|
||||
use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_INBOUND;
|
||||
use windows_sys::Win32::System::Pipes::{
|
||||
CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE,
|
||||
PIPE_WAIT,
|
||||
};
|
||||
|
||||
let name: Vec<u16> = pipe_name()
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// SAFETY: NUL-terminated UTF-16 alive across the call. Inbound only -- the
|
||||
// protocol never answers.
|
||||
//
|
||||
// The security descriptor is the DEFAULT, and what that means is worth
|
||||
// stating correctly rather than assuming: full control for SYSTEM,
|
||||
// administrators and the creator-owner, plus READ for Everyone. Clients
|
||||
// here ask for write, so another ordinary user cannot inject a line, and
|
||||
// Mandatory Integrity Control keeps a low-integrity process out. What CAN
|
||||
// write is a process running as this same user, or an elevated
|
||||
// administrator -- both of which can already do far worse to a process
|
||||
// they own. A logon-SID DACL would close the same-user-other-session case
|
||||
// properly and is the documented way; the session-qualified NAME above
|
||||
// separates the sessions in practice, and `handle_line` validates what
|
||||
// arrives rather than trusting it.
|
||||
//
|
||||
// PIPE_REJECT_REMOTE_CLIENTS because this is strictly local IPC: the
|
||||
// namespace is reachable over SMB and nothing about a second launch is.
|
||||
let h = unsafe {
|
||||
CreateNamedPipeW(
|
||||
name.as_ptr(),
|
||||
PIPE_ACCESS_INBOUND,
|
||||
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
|
||||
1,
|
||||
512,
|
||||
512,
|
||||
0,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if h == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
Some(h as isize)
|
||||
}
|
||||
|
||||
/// Block until one client connects, read what it sends, then close.
|
||||
fn accept_one(handle: isize) -> Option<String> {
|
||||
use std::os::windows::io::FromRawHandle;
|
||||
use windows_sys::Win32::System::Pipes::ConnectNamedPipe;
|
||||
|
||||
// SAFETY: a live pipe handle from `create_pipe_instance`. Blocks until a
|
||||
// client arrives, which is the point of the dedicated thread.
|
||||
let connected = unsafe { ConnectNamedPipe(handle as _, std::ptr::null_mut()) };
|
||||
// A client that connected between CreateNamedPipeW and here makes this
|
||||
// return 0 with ERROR_PIPE_CONNECTED, which is success.
|
||||
if connected == 0 {
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_PIPE_CONNECTED};
|
||||
// SAFETY: reads this thread's last-error.
|
||||
if unsafe { GetLastError() } != ERROR_PIPE_CONNECTED {
|
||||
// SAFETY: taking ownership so the handle is closed on drop.
|
||||
drop(unsafe { std::fs::File::from_raw_handle(handle as _) });
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: ownership moves to the File, which closes the handle on drop and
|
||||
// disconnects the client with it.
|
||||
let mut file = unsafe { std::fs::File::from_raw_handle(handle as _) };
|
||||
let mut buf = String::new();
|
||||
// BOUNDED. The protocol's longest legal line is a URL; anything past this
|
||||
// is not ours, and an unbounded read on a pipe anyone in this session can
|
||||
// write is a free way to grow this process without limit.
|
||||
match file.take(4096).read_to_string(&mut buf) {
|
||||
Ok(_) => Some(buf),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] single-instance read failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole protocol: two verbs.
|
||||
fn handle_line(line: &str) {
|
||||
if let Some(url) = line.strip_prefix("OPEN ") {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return;
|
||||
}
|
||||
// VALIDATE. This is not argv: it arrived on a pipe, and the only
|
||||
// thing keeping it honest is this check. Anything that is not a link
|
||||
// we would have accepted on our own command line is dropped.
|
||||
if !crate::deep_link_qt::is_actionable(url) {
|
||||
log::warn!("[qbz-qt] single-instance: ignoring an unusable OPEN payload");
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"[qbz-qt] deep link forwarded from a second instance: {}",
|
||||
url.split('?').next().unwrap_or(url)
|
||||
);
|
||||
crate::deep_link_qt::stash(url.to_string());
|
||||
present_or_defer();
|
||||
crate::deep_link_qt::drain_pending();
|
||||
} else if line == "PRESENT" {
|
||||
present_or_defer();
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,8 @@ impl qbz_tray::QbzTray {
|
||||
crate::tray_qt::create_now();
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::single_instance_qt::bind_ui();
|
||||
#[cfg(target_os = "windows")]
|
||||
crate::single_instance_win::bind_ui();
|
||||
}
|
||||
|
||||
/// D18's report-back. Flag only — it never touches a window.
|
||||
|
||||
Reference in new issue
Block a user