merge: integrate Windows port from PR #725

cx
This commit is contained in:
vicrodh committed 2026-08-28 04:18:06 -06:00
commit 912638c387
93 files changed
+6794 -261

No files matched your search

+49 -1
View File
@@ -4102,6 +4102,8 @@ dependencies = [
"symphonia",
"thiserror 2.0.18",
"tokio",
"wasapi",
"windows 0.62.2",
"zbus 4.4.0",
]
@@ -4168,6 +4170,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
"windows-sys 0.61.2",
]
[[package]]
@@ -4302,6 +4305,7 @@ dependencies = [
"serde_json",
"tempfile",
"thiserror 2.0.18",
"windows-sys 0.61.2",
]
[[package]]
@@ -4354,10 +4358,12 @@ dependencies = [
"md-5",
"mpris-server",
"notify-rust",
"qbz-models",
"reqwest",
"souvlaki",
"tokio",
"urlencoding",
"windows-sys 0.61.2",
]
[[package]]
@@ -4565,8 +4571,11 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"url",
"urlencoding",
"uuid 1.22.0",
"windows-sys 0.61.2",
"winresource",
"zbus 5.15.0",
]
@@ -4711,9 +4720,10 @@ dependencies = [
"tempfile",
"tiny_http",
"tokio",
"toml",
"toml 0.9.12+spec-1.1.0",
"urlencoding",
"uuid 1.22.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6335,6 +6345,21 @@ dependencies = [
"winnow 0.7.15",
]
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -6703,6 +6728,19 @@ dependencies = [
"try-lock",
]
[[package]]
name = "wasapi"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee144c215d8d31e94c1a599fbaf540ca82a7017e4f1786816b179f334fac751d"
dependencies = [
"log",
"num-integer",
"thiserror 2.0.18",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -7587,6 +7625,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml 1.0.6+spec-1.1.0",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+2 -1
View File
@@ -51,7 +51,8 @@ pub fn spawn(player: Arc<Player>) -> tokio::task::JoinHandle<()> {
let mut interval = tokio::time::interval(Duration::from_secs(TICK_SECS));
loop {
interval.tick().await;
// None on platforms without /proc/meminfo — nothing to do.
// None where no memory probe exists (/proc/meminfo on unix,
// GlobalMemoryStatusEx on Windows) — nothing to do.
let Some(pressure) = qbz_core::system_capabilities::read_memory_pressure() else {
continue;
};
+58 -11
View File
@@ -978,6 +978,7 @@ fn backend_name(b: AudioBackendType) -> &'static str {
AudioBackendType::Alsa => "Alsa",
AudioBackendType::Pulse => "Pulse",
AudioBackendType::Jack => "Jack",
AudioBackendType::WasapiExclusive => "WasapiExclusive",
AudioBackendType::SystemDefault => "SystemDefault",
}
}
@@ -1512,17 +1513,63 @@ fn desktop_paths() -> ProfilePaths {
}
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.ok()
.filter(|h| !h.trim().is_empty())
.or_else(|| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| "unknown".to_string())
/// The machine's name, for user-facing device labels.
///
/// `HOSTNAME` is a SHELL variable rather than an environment one on most Linux
/// setups, hence the file fallback. Windows exports `COMPUTERNAME` and has no
/// `/etc/hostname`, so every Windows install used to land on the same
/// "unknown" and collide on Qobuz Connect.
///
/// The single implementation: `qbz-qt` and `qbzd` both call it and keep only
/// their own last-resort word, which differ on purpose.
pub fn hostname() -> String {
for var in ["HOSTNAME", "COMPUTERNAME"] {
if let Ok(h) = std::env::var(var) {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
}
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
"unknown".to_string()
}
#[cfg(test)]
mod hostname_tests {
/// ONE test, not two: the environment is process-global and cargo runs
/// tests on parallel threads, so a sibling that sets HOSTNAME races a
/// sibling that clears it. Splitting these failed exactly that way.
#[test]
fn computername_is_the_windows_source_and_hostname_still_wins() {
let saved_host = std::env::var("HOSTNAME").ok();
let saved_computer = std::env::var("COMPUTERNAME").ok();
std::env::remove_var("HOSTNAME");
std::env::set_var("COMPUTERNAME", "WINBOX");
assert_eq!(super::hostname(), "WINBOX", "Windows has only COMPUTERNAME");
std::env::set_var("HOSTNAME", "gentoo-box");
assert_eq!(
super::hostname(),
"gentoo-box",
"HOSTNAME keeps precedence, so Linux behaviour does not move"
);
match saved_host {
Some(v) => std::env::set_var("HOSTNAME", v),
None => std::env::remove_var("HOSTNAME"),
}
match saved_computer {
Some(v) => std::env::set_var("COMPUTERNAME", v),
None => std::env::remove_var("COMPUTERNAME"),
}
}
}
fn now_rfc3339() -> String {
+12
View File
@@ -64,3 +64,15 @@ jack = "0.13"
# CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode
coreaudio-rs = "0.14.1"
objc2-core-audio = "0.3.2"
[target.'cfg(windows)'.dependencies]
# WASAPI exclusive mode (phase B). HEnquist's crate, the one CamillaDSP
# uses. `windows` is pinned to the 0.62 line cpal already resolves, so
# this adds no second windows tree to the graph.
wasapi = "0.24"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Media_Audio",
"Win32_System_Com",
"Win32_System_Threading",
] }
+221
View File
@@ -0,0 +1,221 @@
//! Throwaway proof for the WASAPI exclusive path (phase B, task 2 step 8).
//!
//! Opens a render endpoint in EXCLUSIVE mode and plays a 1 kHz sine, then
//! reports what the stream negotiated and how many underruns it took.
//!
//! Deliberately quiet: -20 dBFS. A stability proof does not need full scale,
//! and this runs on somebody's actual monitors.
//!
//! cargo run -p qbz-audio --example wasapi_play list endpoints
//! cargo run -p qbz-audio --example wasapi_play <id> [rate] [s] play
//!
//! What it proves that a unit test cannot: the DAC's own display shows the
//! rate we asked for. If the display says 48 kHz while this says 96, the
//! stream is being resampled somewhere and it is NOT bit-perfect.
fn main() {
#[cfg(not(windows))]
{
eprintln!("wasapi_play is Windows-only");
}
#[cfg(windows)]
{
use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming};
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
list_endpoints();
eprintln!();
eprintln!("pass an endpoint id to play, e.g.");
eprintln!(" cargo run -p qbz-audio --example wasapi_play \"{{0.0.0...}}\" 96000 10");
return;
}
let endpoint = args[0].clone();
let rate: u32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(96_000);
let secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10);
let stream = match WasapiDirectStream::new(&endpoint, rate, 2, WasapiTiming::Events) {
Ok(s) => s,
Err(e) => {
eprintln!("open failed: {e}");
return;
}
};
let info = stream.open_info();
println!("endpoint : {}", info.endpoint_name);
println!("rate : {} Hz", info.rate);
println!(
"format : {:?} ({}/{} bits)",
info.rung,
info.rung.valid_bits(),
info.rung.container_bits()
);
println!(
"period : {} hns buffer: {} frames",
info.period_hns, info.buffer_frames
);
println!("realigned: {} timing: {:?}", info.realigned, info.timing);
println!("bit-perfect mode: {:?}", stream.bit_perfect_mode());
println!();
println!("BEEP 1 of 3 - {secs}s of 1 kHz at -20 dBFS");
println!("CHECK THE DAC: it must read {rate}. Then expect TWO more one-second");
println!("beeps - one after the drain, one after the stop. Three beeps = PASS.");
// Feed in period-sized chunks so the queue's back-pressure paces us
// instead of a sleep. 1 kHz divides every rate in the ladder evenly
// enough that the phase never accumulates audible drift over 10 s.
let frames_per_chunk = info.buffer_frames.max(1) as usize;
let total_frames = rate as u64 * secs;
let amp = 0.1f32; // -20 dBFS
let step = 2.0 * std::f32::consts::PI * 1000.0 / rate as f32;
let mut phase = 0.0f32;
let mut written: u64 = 0;
while written < total_frames {
let n = frames_per_chunk.min((total_frames - written) as usize);
let mut buf = Vec::with_capacity(n * 2);
for _ in 0..n {
let v = phase.sin() * amp;
phase += step;
if phase > std::f32::consts::TAU {
phase -= std::f32::consts::TAU;
}
buf.push(v); // L
buf.push(v); // R
}
if let Err(e) = stream.write_f32(&buf) {
eprintln!("write failed after {written} frames: {e}");
break;
}
written += n as u64;
}
// The verdict must be judged on CONTINUOUS playback only. Everything
// after this point deliberately leaves the queue empty between beeps,
// and a genuine gap with the stream running IS an underrun -- counting
// those would make this instrument report a failure it created itself.
let underruns_playing = stream.underruns();
let timeouts_playing = stream.event_timeouts();
// Drain, then WRITE AGAIN. This is the discriminating test for the
// gapless contract: drain must block until the queue has played out
// AND must leave the stream usable for the next source. An early
// return, or a render thread that exits on drain, both show up here
// and nowhere else.
let t0 = std::time::Instant::now();
let drained = stream.drain();
let drain_ms = t0.elapsed().as_millis();
println!();
println!("drain : {drained:?} in {drain_ms} ms");
// A FULL SECOND, not one buffer. The point of these two checks is that
// a person can HEAR the second and third beeps; a 144-frame burst at
// 44.1 kHz is 3 ms, which is inaudible, so the test could pass or fail
// and sound identical either way.
let mut beep = |stream: &WasapiDirectStream, phase: &mut f32| -> Result<(), String> {
let mut left = rate as usize;
while left > 0 {
let n = frames_per_chunk.min(left);
let mut buf = Vec::with_capacity(n * 2);
for _ in 0..n {
let v = phase.sin() * amp;
*phase += step;
if *phase > std::f32::consts::TAU {
*phase -= std::f32::consts::TAU;
}
buf.push(v);
buf.push(v);
}
stream.write_f32(&buf)?;
left -= n;
}
Ok(())
};
println!("BEEP 2 of 3 - one second, after the drain");
match beep(&stream, &mut phase) {
Ok(()) => println!("write after drain: OK - the stream survived the drain"),
Err(e) => println!("write after drain: FAILED ({e}) - the stream died on drain"),
}
let _ = stream.drain();
// STOP, then WRITE AGAIN. This is the OTHER half of the gapless
// contract and the one that was missing: the player calls
// engine.stop() on EVERY track change and then reuses the same stream
// object for the next track, exactly as ALSA's stop() does
// ("prepare for next playback"). A render thread that exits on Stop
// passes every drain test and still cannot play a second track --
// which is what shipped, and what the app log caught as
// "Write failed: render thread is gone".
let t1 = std::time::Instant::now();
let stopped = stream.stop();
println!(
"stop : {stopped:?} in {} ms",
t1.elapsed().as_millis()
);
println!("BEEP 3 of 3 - one second, after the stop");
let survived_stop = match beep(&stream, &mut phase) {
Ok(()) => {
println!("write after stop : OK - the stream survived the stop");
true
}
Err(e) => {
println!("write after stop : FAILED ({e}) - the stream died on stop");
false
}
};
let _ = stream.drain();
println!();
println!("frames written : {written}");
println!(
"underruns : {} during playback ({} total, gaps between beeps included)",
underruns_playing,
stream.underruns()
);
println!(
"event timeouts : {} during playback ({} total)",
timeouts_playing,
stream.event_timeouts()
);
println!(
"VERDICT : {}",
if !survived_stop {
"FAIL - the stream did not survive stop(); a track change kills it"
} else if underruns_playing == 0 && timeouts_playing == 0 {
"PASS - survived drain and stop, no underruns, no timeouts"
} else {
"underruns occurred; try WasapiTiming::Polling"
}
);
}
}
#[cfg(windows)]
fn list_endpoints() {
use wasapi::{initialize_mta, DeviceEnumerator, Direction};
let _ = initialize_mta().ok();
let Ok(en) = DeviceEnumerator::new() else {
eprintln!("DeviceEnumerator failed");
return;
};
let Ok(coll) = en.get_device_collection(&Direction::Render) else {
eprintln!("device collection failed");
return;
};
let n = coll.get_nbr_devices().unwrap_or(0);
println!("render endpoints:");
for i in 0..n {
if let Ok(d) = coll.get_device_at_index(i) {
println!(
" [{i}] {}",
d.get_friendlyname().unwrap_or_else(|_| "<unnamed>".into())
);
println!(" {}", d.get_id().unwrap_or_else(|_| "<no id>".into()));
}
}
}
+33
View File
@@ -1227,3 +1227,36 @@ mod tests {
assert!(!uses_alsa_direct_route(&audio));
}
}
/// Forwards to the inherent methods, which is all this is: no behaviour is
/// added, moved or wrapped. See `backend::DirectSink`.
impl crate::backend::DirectSink for AlsaDirectStream {
fn write_f32(&self, samples: &[f32]) -> Result<(), String> {
AlsaDirectStream::write_f32(self, samples)
}
fn drain(&self) -> Result<(), String> {
AlsaDirectStream::drain(self)
}
fn stop(&self) -> Result<(), String> {
AlsaDirectStream::stop(self)
}
fn sample_rate(&self) -> u32 {
AlsaDirectStream::sample_rate(self)
}
fn channels(&self) -> u16 {
AlsaDirectStream::channels(self)
}
fn playback_delay_frames(&self) -> Result<u64, String> {
AlsaDirectStream::playback_delay_frames(self)
}
fn log_label(&self) -> &'static str {
"ALSA Direct Engine"
}
/// Linux only, because the inherent method is: the non-Linux stub of this
/// type has no mixer at all, and there the trait default refusal is the
/// honest answer.
#[cfg(target_os = "linux")]
fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
AlsaDirectStream::set_hardware_volume(self, volume)
}
}
+165 -5
View File
@@ -42,6 +42,16 @@ pub enum AudioBackendType {
/// bit-perfect ALSA-exclusive / DAC-passthrough paths.
Jack,
/// WASAPI EXCLUSIVE (Windows) - the bit-perfect path.
/// - Takes the endpoint exclusively; other apps go silent
/// - No resampling: the DAC is commanded to the track's own rate
/// - Selecting a device is required, by ENDPOINT ID (Windows gives several
/// endpoints the same friendly name)
/// The trait's `create_output_stream` for this type is the SHARED CPAL one:
/// the exclusive stream bypasses the trait entirely, exactly as ALSA Direct
/// and JACK do.
WasapiExclusive,
/// System default backend (non-Linux platforms)
/// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows)
/// - Automatic device selection via OS audio system
@@ -308,6 +318,14 @@ impl BackendManager {
backends.push(AudioBackendType::SystemDefault);
}
#[cfg(windows)]
{
// Offered unconditionally: whether a given DEVICE accepts exclusive
// mode is answered per device when the stream opens, not here, and
// hiding the backend would hide the answer too.
backends.push(AudioBackendType::WasapiExclusive);
}
backends
}
@@ -333,6 +351,14 @@ impl BackendManager {
Ok(Box::new(CpalDefaultBackend::new()?))
}
}
AudioBackendType::WasapiExclusive => {
// The SHARED stream, deliberately. `create_stream_for_backend`
// tries the exclusive stream first and only reaches here when
// that is impossible - no device chosen, or the endpoint
// refusing every format - so this is the graceful fallback
// rather than the intended path.
Ok(Box::new(CpalDefaultBackend::new()?))
}
AudioBackendType::SystemDefault => {
// "System": play through the OS default output via CPAL's default
// host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux
@@ -456,6 +482,20 @@ impl AudioBackend for CpalDefaultBackend {
.map(|desc| desc.name().to_string())
.unwrap_or_else(|_| "Default Output".to_string());
// WINDOWS ONLY, additive. Endpoint FRIENDLY NAMES are not unique
// there: measured on a real box, three active render endpoints were
// all called "Altavoces" - the motherboard Realtek, a Bluetooth
// handsfree and a Cambridge Audio USB DAC. Matching the default by
// name would mark all three.
#[cfg(windows)]
let default_endpoint_id = default_device.id().ok().map(|i| i.1);
// Idempotent, and this is the natural place: the first time anything
// asks what devices exist is the first time it matters that the answer
// can go stale.
#[cfg(windows)]
crate::wasapi_backend::start_hotplug_watch();
let mut devices = Vec::new();
for device in self
.host
@@ -466,23 +506,92 @@ impl AudioBackend for CpalDefaultBackend {
.description()
.map(|desc| desc.name().to_string())
.unwrap_or_else(|_| "Unknown Device".to_string());
let is_default = name == default_name;
// On macOS, probe device capabilities via CoreAudio
#[cfg(target_os = "macos")]
let (supported_rates, max_rate, bus_type, is_hw) = { Self::probe_macos_device(&name) };
#[cfg(not(target_os = "macos"))]
// The placeholder for every platform that has no probe. On
// Windows `supported_rates`/`max_rate` are SHADOWED further down
// by the exclusive-mode sweep, which is why they carry an
// underscore prefix there and nowhere else.
#[cfg(all(not(target_os = "macos"), not(windows)))]
let (supported_rates, max_rate, bus_type, is_hw): (
Option<Vec<u32>>,
Option<u32>,
Option<String>,
bool,
) = (None, None, None, false);
#[cfg(windows)]
let (bus_type, is_hw): (Option<String>, bool) = (None, false);
// WINDOWS ONLY, additive: id, display and description all come
// from fields cpal already fills but this backend ignored.
//
// `name` is the endpoint ("Altavoces") and repeats across
// devices; `driver()` is the ADAPTER ("Cambridge Audio USB Audio
// 2.0") and is what tells them apart; `id()` is the stable WASAPI
// endpoint GUID. Using the name as the id meant device_filter
// folded every same-named endpoint into one entry, so a USB DAC
// sharing the name "Altavoces" with the onboard card could not be
// selected at all - and an unselectable DAC blocks bit-perfect.
#[cfg(windows)]
let (id, display, description, is_default) = {
let driver = device
.description()
.ok()
.and_then(|d| d.driver().map(str::to_string))
.filter(|d| !d.is_empty() && d.as_str() != name.as_str());
let endpoint_id = device.id().ok().map(|i| i.1);
let display = match &driver {
Some(d) => format!("{name} ({d})"),
None => name.clone(),
};
let is_default = match (&endpoint_id, &default_endpoint_id) {
(Some(a), Some(b)) => a == b,
_ => name == default_name,
};
(
endpoint_id.unwrap_or_else(|| name.clone()),
display,
driver,
is_default,
)
};
#[cfg(not(windows))]
let (id, display, description, is_default) =
(name.clone(), name.clone(), None, name == default_name);
// WINDOWS: the rates the endpoint accepts in EXCLUSIVE mode, which
// is the only list that describes what reaches the converter
// untouched. Shadows the `(None, None, ...)` placeholder above.
//
// It cannot be inferred, which is the whole reason it is measured:
// the owner's DAC accepts 44100/48000/88200/96000/192000 and
// REFUSES 176400, so the 44.1 family stops at 88.2 while the 48
// family reaches 192. Cached per endpoint, so this costs once.
//
// `supported_rates` answers `None` when the endpoint could not
// be asked at all -- busy, gone, or COM refused -- and that must
// not reach a caller as "supports nothing", which would narrow a
// quality cap on the strength of a failed question. An endpoint
// that answers and lists nothing is reported the same way for the
// same reason: there is nothing to offer either way, and neither
// is a fact worth caching upstream.
#[cfg(windows)]
let (supported_rates, max_rate) = {
match crate::wasapi_backend::supported_rates(&id) {
Some(rates) if !rates.is_empty() => {
let top = rates.iter().copied().max();
(Some(rates), top)
}
_ => (None, None),
}
};
devices.push(AudioDevice {
id: name.clone(),
name,
description: None,
id,
name: display,
description,
is_default,
max_sample_rate: max_rate,
supported_sample_rates: supported_rates,
@@ -563,6 +672,14 @@ impl AudioBackend for CpalDefaultBackend {
.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
.find(|d| {
// WINDOWS ONLY, additive: enumerate_devices publishes the
// stable endpoint GUID as the id there, because friendly
// names repeat. The name comparison stays as the fallback
// so a setting saved before this change still resolves.
#[cfg(windows)]
if d.id().map(|i| i.1 == *device_id).unwrap_or(false) {
return true;
}
d.description()
.map(|desc| desc.name() == device_id.as_str())
.unwrap_or(false)
@@ -933,3 +1050,46 @@ impl CpalDefaultBackend {
}
}
}
/// The two-method surface a bit-perfect direct stream owes the player's writer
/// thread, plus the three it owes the engine around it.
///
/// It exists so ONE writer thread serves both `AlsaDirectStream` and
/// `WasapiDirectStream` instead of two copies drifting apart. The set is
/// exactly what `playback_engine.rs` already called on the ALSA stream - no
/// method was invented for the trait, and neither implementation gains or
/// loses behaviour by having it.
pub trait DirectSink: Send + Sync + 'static {
/// Interleaved f32, blocking when the device's queue is full. That block
/// IS the back-pressure that paces the writer thread.
fn write_f32(&self, samples: &[f32]) -> Result<(), String>;
/// Play out what is queued, then return. Bounded internally.
fn drain(&self) -> Result<(), String>;
fn stop(&self) -> Result<(), String>;
fn sample_rate(&self) -> u32;
fn channels(&self) -> u16;
/// Frames submitted to the sink but not yet heard, when the backend can
/// measure them. The visualizer uses this only as latency compensation;
/// an unsupported query keeps playback intact and falls back to the
/// bounded direct-writer pacing.
fn playback_delay_frames(&self) -> Result<u64, String> {
Err("this direct sink cannot measure queued playback frames".to_string())
}
/// Log prefix for this sink. On the trait rather than a parameter so a
/// call site cannot label an ALSA stream "WASAPI" or the reverse - which
/// is exactly what a hard-coded label at four call sites invites.
fn log_label(&self) -> &'static str;
/// Device-side volume, where the sink has one.
///
/// The default REFUSES rather than silently succeeding: a sink without a
/// hardware mixer must not pretend it moved the volume. Every call site is
/// already gated on the engine's `hardware_volume` flag, which is false for
/// any such sink - permanently so for WASAPI exclusive, where the phase B
/// contract rules out ISimpleAudioVolume.
fn set_hardware_volume(&self, _volume: f32) -> Result<(), String> {
Err("this sink has no hardware volume".to_string())
}
}
+14
View File
@@ -11,6 +11,8 @@
//! bit-perfect / sample-rate-passthrough path is untouched.
use serde::{Deserialize, Serialize};
// Every target but Windows still runs `pw-dump`.
#[cfg(not(windows))]
use std::process::Command;
/// The DAC's live, negotiated hardware state — what the card is REALLY clocked
@@ -94,6 +96,18 @@ pub fn parse_alsa_card_for_node(json: &str, node_name: &str) -> Option<u32> {
}
/// Resolve the ALSA card number for a sink node by running `pw-dump`.
///
/// W14, Windows only: `pw-dump` does not exist there, so the spawn already
/// failed into this same None. Scoped to Windows rather than `not(linux)` --
/// a macOS or BSD box can have a real `pw-dump`, and this function is not the
/// place to decide it may not speak.
#[cfg(windows)]
fn alsa_card_for_node(_node_name: &str) -> Option<u32> {
None
}
/// Resolve the ALSA card number for a sink node by running `pw-dump`.
#[cfg(not(windows))]
fn alsa_card_for_node(node_name: &str) -> Option<u32> {
let output = Command::new("pw-dump").output().ok()?;
if !output.status.success() {
+29 -1
View File
@@ -5,6 +5,8 @@
//! the check step can show the right `apt`/`dnf`/`pacman` remediation. None of
//! this opens a stream — purely diagnostic.
// Every target but Windows still probes; see `audio_stack_health`.
#[cfg(not(windows))]
use std::process::Command;
/// Result of the audio-stack probes. All best-effort; a failed probe reads as
@@ -32,7 +34,8 @@ impl AudioStackHealth {
}
}
/// True if `sh -c "<probe>"` exits 0.
/// True if `sh -c "<probe>"` exits 0. Windows never probes; see below.
#[cfg(not(windows))]
fn sh_ok(probe: &str) -> bool {
Command::new("sh")
.arg("-c")
@@ -44,6 +47,31 @@ fn sh_ok(probe: &str) -> bool {
/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything
/// reads false except where trivially true.
///
/// W14: on WINDOWS ONLY the answer is all-false, returned WITHOUT running the
/// probes. `systemctl`, `pactl` and `pw-dump` do not exist there (a spawn of a
/// missing image fails before a process is made, which is harmless), but `sh`
/// DOES exist wherever Git for Windows is installed, so the five `sh -c`
/// probes really run and flash five console windows past a GUI-subsystem
/// binary that owns no console.
///
/// Scoped to Windows, not to `not(linux)`: macOS and the BSDs can have a real
/// `pactl`/`pw-dump` from Homebrew or ports, and answering false for them
/// would be a lie this diff has no business telling.
#[cfg(windows)]
pub fn audio_stack_health() -> AudioStackHealth {
AudioStackHealth {
wireplumber_active: false,
has_pw_dump: false,
cpal_sees_pipewire: false,
has_pactl: false,
any_devices: false,
}
}
/// Run the audio-stack probes. Linux-only meaningfully; elsewhere everything
/// reads false except where trivially true.
#[cfg(not(windows))]
pub fn audio_stack_health() -> AudioStackHealth {
// systemd path first; fall back to a process check so non-systemd inits
// (Gentoo/OpenRC, Void/runit) don't read as "WirePlumber down".
+5
View File
@@ -29,6 +29,10 @@
#[cfg(target_os = "linux")]
pub mod alsa_backend;
pub mod alsa_direct;
pub mod wasapi_direct;
/// Endpoint capabilities: the exclusive-mode rate sweep and the hotplug watch.
#[cfg(windows)]
pub mod wasapi_backend;
#[cfg(target_os = "linux")]
pub mod alsa_error_handler;
pub mod analysis;
@@ -64,6 +68,7 @@ pub use alsa_backend::{
resolve_stable_to_current_hw,
};
pub use alsa_direct::AlsaDirectStream;
pub use wasapi_direct::{Rung, LADDER};
pub use analysis::SpectralAnalyzer;
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap, AnalyzerWaveformTrack};
pub use backend::{
+374
View File
@@ -0,0 +1,374 @@
//! WASAPI endpoint capabilities: what rates a device accepts in EXCLUSIVE
//! mode, and being told when the device list changes.
//!
//! The Windows counterpart of `alsa_backend::get_device_supported_rates`. It
//! exists because "what can this DAC actually do" cannot be inferred: the
//! spike measured a Cambridge Audio USB DAC accepting 44100, 48000, 88200,
//! 96000 and 192000 while REFUSING 176400 -- the 44.1 family stops at 88.2
//! while the 48 family reaches 192, which no rule about "higher rates imply
//! lower ones" predicts. The owner confirmed it from the hardware, and a
//! SECOND endpoint on the same machine does accept 176400, so the difference
//! is the device rather than the driver stack.
//!
//! ## Exclusive mode is the question, and it is a different question
//!
//! `IsFormatSupported(SHARED)` answers about the mixer, which resamples and
//! therefore says yes to almost everything. Only the EXCLUSIVE answer says
//! what reaches the converter untouched, which is the only answer bit-perfect
//! playback cares about.
//!
//! ## "I could not ask" is not "it supports nothing"
//!
//! A busy endpoint, a failed activation and a device that genuinely accepts no
//! rate all produce an empty list, and collapsing them was a real defect in the
//! first draft: the empty answer was cached as authoritative, and because a
//! stream CLOSING raises no device notification, it would have outlived the
//! condition that caused it. [`Caps`] keeps them apart, and only `Known` is
//! ever stored.
#![cfg(windows)]
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use crate::wasapi_direct::LADDER;
/// The rates worth asking about: the two families QBZ streams and their
/// multiples, plus the DSD-over-PCM carriers. 352800 and 384000 are asked
/// because a DAC that takes them changes what a quality cap can offer.
const SWEEP: [u32; 8] = [
44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000,
];
/// What a probe learned, which is not always a capability.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Caps {
/// The device answered. An empty vector HERE is a real "nothing".
Known(Vec<u32>),
/// The device could not be asked -- busy, gone, or COM refused. Never
/// cached: nothing about it is a property of the hardware.
Unavailable,
}
/// Bumped by every device notification. A cached answer is only good while
/// this has not moved.
///
/// A COUNTER rather than per-endpoint removal, and that is the point: the
/// notifications arrive on a thread the Windows audio system owns, and
/// Microsoft's rule there is DO NOT BLOCK. `fetch_add` cannot block, cannot
/// fail and cannot lose an invalidation, where taking the cache's mutex would
/// block a thread that was told not to, and a `try_lock` that skipped on
/// contention would silently keep a stale answer.
///
/// Invalidating EVERYTHING on any event is also the honest granularity: a
/// device arriving or leaving changes exclusive-mode contention for endpoints
/// other than itself, so "only that one changed" was never true.
static GENERATION: AtomicU64 = AtomicU64::new(0);
/// Endpoint id -> (the generation it was measured in, the rates it accepted).
fn cache() -> &'static Mutex<HashMap<String, (u64, Vec<u32>)>> {
static CACHE: OnceLock<Mutex<HashMap<String, (u64, Vec<u32>)>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Serialises the probing itself, so two callers that miss together do the
/// work once instead of twice.
///
/// MEASURED, not theoretical: startup enumerates from two independent places
/// -- the device-capability pass and the DAC-capability pass -- and the log
/// showed every endpoint swept exactly twice, both racing past the empty
/// cache. Holding this across the sweep costs the second caller about a
/// millisecond per endpoint and saves it the same again.
///
/// SEPARATE from the cache mutex on purpose. The cache lock is taken for a
/// `get` and an `insert` and released immediately; this one is held across COM
/// calls, and merging them would make every reader wait behind a probe. The
/// hotplug callbacks touch NEITHER -- they only bump an atomic -- so nothing
/// the audio system owns can ever wait on either lock.
fn sweep_gate() -> &'static Mutex<()> {
static GATE: OnceLock<Mutex<()>> = OnceLock::new();
GATE.get_or_init(|| Mutex::new(()))
}
/// The cache key: the endpoint, and the QUESTION the stored answer answers.
///
/// The value is "some rung of the ladder accepts this rate", so naming one
/// rung would claim a precision it does not have. `LADDER.len()` is part of it
/// because growing the ladder changes the answer for the same device, and an
/// entry written under a shorter ladder must not survive that.
pub(crate) fn rate_sweep_key(endpoint_id: &str) -> String {
format!("{endpoint_id}|any-of-{}-rungs", LADDER.len())
}
/// COM is initialised at most ONCE per thread, and deliberately never undone.
///
/// `initialize_mta` should be balanced by a `CoUninitialize`, but the threads
/// that reach here are pool workers that get reused: uninitialising on the way
/// out would tear the apartment down under whatever COM work the same thread
/// runs next, and doing it per sweep would re-enter the apartment on every
/// call. One initialisation per worker for the life of the process is the
/// bounded cost, and it is the trade any COM-using code on a pool makes.
///
/// `RPC_E_CHANGED_MODE` -- this thread is already an STA -- is not an error to
/// report: `IsFormatSupported` is happy either way.
fn ensure_com() {
thread_local! {
static DONE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
DONE.with(|d| {
if !d.get() {
let _ = wasapi::initialize_mta().ok();
d.set(true);
}
});
}
/// The rates `endpoint_id` accepts in EXCLUSIVE mode.
///
/// `None` = the endpoint could not be asked. Callers must NOT read that as
/// "supports nothing" -- see [`Caps`] -- and in particular must not use it to
/// justify sending a device a format it has not agreed to.
pub fn supported_rates(endpoint_id: &str) -> Option<Vec<u32>> {
let key = rate_sweep_key(endpoint_id);
// Read BEFORE probing, and store against THIS value rather than the
// current one. If a device event lands while the sweep runs, the result is
// already stale and the next caller re-measures instead of trusting it.
let generation = GENERATION.load(Ordering::SeqCst);
// The lock is never held across the sweep below: that is milliseconds of
// COM calls, and nothing else may be made to wait on it.
if let Ok(guard) = cache().lock() {
if let Some((gen, hit)) = guard.get(&key) {
if *gen == generation {
return Some(hit.clone());
}
}
}
// Take the gate, then look again: the caller that held it may have been
// measuring this very endpoint, in which case its answer is now in the
// cache and there is nothing left to do.
let _gate = sweep_gate().lock();
if let Ok(guard) = cache().lock() {
if let Some((gen, hit)) = guard.get(&key) {
if *gen == generation {
return Some(hit.clone());
}
}
}
match sweep(endpoint_id) {
Caps::Known(rates) => {
if let Ok(mut guard) = cache().lock() {
guard.insert(key, (generation, rates.clone()));
}
Some(rates)
}
// NOT cached. A busy or absent endpoint says nothing about the
// hardware, and a stream closing raises no notification to clear it.
Caps::Unavailable => None,
}
}
/// The highest rate the endpoint accepts, or `None` if it could not be asked
/// or accepted nothing.
pub fn max_supported_rate(endpoint_id: &str) -> Option<u32> {
supported_rates(endpoint_id)?.into_iter().max()
}
/// Drop everything we believe about every endpoint.
///
/// Lock-free by construction, because the hotplug callbacks call it from a
/// thread the audio system owns and are required not to block there.
pub fn invalidate_all() {
GENERATION.fetch_add(1, Ordering::SeqCst);
}
/// Ask the device about every rate in `SWEEP`, at every rung of the format
/// ladder, and keep a rate the moment ANY rung accepts it.
///
/// Walking the ladder rather than one rung matters: the spike's DAC accepts
/// ONLY 24-in-32 and refuses packed 24, 32-bit, 16-bit and float, so a sweep
/// pinned to a single rung would have reported that it supports nothing.
fn sweep(endpoint_id: &str) -> Caps {
use wasapi::{DeviceEnumerator, SampleType, ShareMode, WaveFormat};
ensure_com();
let Ok(enumerator) = DeviceEnumerator::new() else {
log::warn!("[wasapi-caps] no device enumerator; capabilities unknown");
return Caps::Unavailable;
};
let Ok(device) = enumerator.get_device(endpoint_id) else {
log::warn!("[wasapi-caps] endpoint {endpoint_id} unavailable; capabilities unknown");
return Caps::Unavailable;
};
// ONE client for every question. `IsFormatSupported` may be called at any
// time after activation and neither initialises nor reserves the endpoint,
// so a client per probe was forty activations buying nothing. Only an
// INITIALISED exclusive client reserves a device, and this one never is.
let Ok(client) = device.get_iaudioclient() else {
log::warn!("[wasapi-caps] cannot activate {endpoint_id}; capabilities unknown");
return Caps::Unavailable;
};
let mut rates = Vec::new();
for rate in SWEEP {
let accepted = LADDER.iter().any(|rung| {
// The same shape `wasapi_direct`'s open path builds, and it must
// stay the same shape: a rung probed one way and opened another is
// a device that says yes and then refuses.
let ty = if rung.is_float() {
SampleType::Float
} else {
SampleType::Int
};
let format = WaveFormat::new(
rung.container_bits() as usize,
rung.valid_bits() as usize,
&ty,
rate as usize,
2,
None,
);
// `is_supported`, NOT `is_supported_exclusive_with_quirks`. That
// helper retries as a plain WAVEFORMATEX and with alternative
// channel masks, and using it HERE ALONE would be a mistake: this
// sweep must predict what the OPEN path does, and that path asks
// the plain question. A rate accepted only through a quirk the
// opener never tries is a rate that appears in the UI and then
// fails to open -- worse than under-reporting.
//
// Teaching the opener those quirks is worth doing for DACs that
// need them, and it has to happen in BOTH places in one change, or
// the two disagree again in the other direction.
//
// In exclusive mode `ppClosestMatch` is null, so the answer is an
// exact yes or an error: there is no S_FALSE "closest match" to
// weigh, which is shared-mode behaviour.
client.is_supported(&format, &ShareMode::Exclusive).is_ok()
});
if accepted {
rates.push(rate);
}
}
log::info!("[wasapi-caps] {endpoint_id} accepts {rates:?} in exclusive mode");
Caps::Known(rates)
}
// ---------------------------------------------------------------------------
// Hotplug
// ---------------------------------------------------------------------------
/// Whether the watch thread is running. The REGISTRATION itself cannot live in
/// a static: it holds COM interface pointers and is therefore not `Send`.
static WATCH_STARTED: AtomicBool = AtomicBool::new(false);
/// Start listening for endpoints arriving, leaving or changing state.
///
/// Idempotent, and it RE-ARMS: if the audio service is unavailable when this
/// first runs, the flag is cleared so a later call can retry. A transient
/// failure must not disable the watch for the life of the process, which the
/// first draft did by resetting only when the thread failed to spawn.
///
/// SCOPE, stated plainly: this keeps the CAPABILITY CACHE honest. It does not
/// refresh an already-rendered device list -- a newly attached DAC appears
/// when something enumerates again, not the instant it is plugged in. Making
/// the settings model live needs a bridge signal and belongs with that model.
pub fn start_hotplug_watch() {
if WATCH_STARTED.swap(true, Ordering::SeqCst) {
return;
}
// A DEDICATED THREAD that owns the registration and then parks forever.
//
// Not a matter of style: `DeviceEventRegistration` holds COM interface
// pointers, so it is not `Send` and cannot be stored in a static. Keeping
// it on the stack of a thread that never returns is what keeps it alive,
// and dropping it is exactly what unregisters the callbacks -- so the
// thread outliving everything IS the lifetime management. It also gives
// the registration its own apartment rather than borrowing whichever
// thread happened to call first.
let spawned = std::thread::Builder::new()
.name("qbz-wasapi-hotplug".to_string())
.spawn(|| {
use wasapi::{DeviceEnumerator, DeviceEventCallbacks};
ensure_com();
let Ok(enumerator) = DeviceEnumerator::new() else {
log::warn!("[wasapi-caps] hotplug watch unavailable; will retry on next request");
WATCH_STARTED.store(false, Ordering::SeqCst);
return;
};
// EVERY callback below runs on a thread the Windows audio system
// owns, where the documented rule is that they return IMMEDIATELY.
// So each does one lock-free atomic increment and NOTHING else --
// no mutex, and no logging either: a log macro can allocate, take
// its own lock and perform synchronous output, and "it is only a
// log line" is exactly how that rule gets broken.
let mut callbacks = DeviceEventCallbacks::new();
callbacks.set_device_removed_callback(|_id| invalidate_all());
callbacks.set_device_added_callback(|_id| invalidate_all());
callbacks.set_device_state_callback(|_id, _state| invalidate_all());
// Reconfiguring a device in Windows' own Sound settings changes
// what it accepts without adding or removing anything. The first
// draft's comment promised this and installed nothing.
callbacks.set_property_value_callback(|_id, _key| invalidate_all());
let _registration = match enumerator.register_notification_callback(callbacks) {
Ok(reg) => reg,
Err(e) => {
log::warn!("[wasapi-caps] could not register the hotplug watch: {e}");
WATCH_STARTED.store(false, Ordering::SeqCst);
return;
}
};
log::info!("[wasapi-caps] hotplug watch registered");
// Park. `_registration` must not be dropped, and there is nothing
// for this thread to do: notifications arrive on WASAPI's own
// thread, not this one.
loop {
std::thread::park();
}
});
if let Err(e) = spawned {
WATCH_STARTED.store(false, Ordering::SeqCst);
log::warn!("[wasapi-caps] could not spawn the hotplug watch thread: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cache_key_says_what_the_answer_means() {
// The stored value is "some rung accepts this rate", so the key must
// not name one rung -- and it must change if the ladder grows, or an
// entry written under a shorter ladder would outlive its question.
let key = rate_sweep_key("{0.0.0.x}");
assert!(key.starts_with("{0.0.0.x}|"));
assert!(key.contains(&LADDER.len().to_string()));
assert_ne!(rate_sweep_key("{0.0.0.x}"), rate_sweep_key("{0.0.0.y}"));
}
#[test]
fn unavailable_is_not_an_empty_capability() {
// The distinction the first draft collapsed: a busy endpoint and one
// that genuinely accepts nothing are different answers, and only the
// second is a fact about the hardware.
assert_ne!(Caps::Unavailable, Caps::Known(Vec::new()));
assert_eq!(Caps::Known(vec![44_100]), Caps::Known(vec![44_100]));
}
#[test]
fn a_device_event_expires_every_cached_answer() {
let before = GENERATION.load(Ordering::SeqCst);
invalidate_all();
assert_ne!(GENERATION.load(Ordering::SeqCst), before);
}
}
File diff suppressed because it is too large. Load diff
+7
View File
@@ -30,6 +30,13 @@ anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(windows)'.dependencies]
# W14: GlobalMemoryStatusEx stands in for /proc/meminfo. 0.61 is already in
# the workspace lock (qbz-local-catalog, qbzd), so this pulls nothing new.
windows-sys = { version = "0.61", features = [
"Win32_System_SystemInformation",
] }
[features]
default = []
# Tier 2 features (to be added later)
+59 -7
View File
@@ -141,14 +141,55 @@ pub fn pressure_from_figures(mem_available_kb: u64, mem_total_kb: u64) -> Memory
}
}
/// Read /proc/meminfo and return a pressure snapshot relative to the
/// detected MemoryProfile. Returns None on platforms without
/// /proc/meminfo or when the file is unreadable.
/// Windows' answer to `/proc/meminfo`: total and available physical memory,
/// both in KiB, from `GlobalMemoryStatusEx`.
///
/// W14. Without this the profile falls back to the top tier and
/// `read_memory_pressure` returns `None` forever, so the memory watchdog is
/// blind on Windows and the prefetch/buffer tiers never step down on a small
/// machine. `ullAvailPhys` is the closest analogue to `MemAvailable`: both
/// mean "what a new allocation can expect without swapping".
#[cfg(windows)]
fn global_memory_status_kb() -> Option<(u64, u64)> {
use windows_sys::Win32::System::SystemInformation::{
GlobalMemoryStatusEx, MEMORYSTATUSEX,
};
let mut status = MEMORYSTATUSEX {
dwLength: std::mem::size_of::<MEMORYSTATUSEX>() as u32,
..unsafe { std::mem::zeroed() }
};
// SAFETY: `status` is a live, correctly sized MEMORYSTATUSEX whose
// `dwLength` is set as the API requires; the call only writes into it.
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
if ok == 0 {
return None;
}
// The fields are bytes; the rest of this module speaks KiB.
Some((status.ullTotalPhys / 1024, status.ullAvailPhys / 1024))
}
/// Read the platform's memory figures and return a pressure snapshot relative
/// to the detected MemoryProfile. Returns None on platforms with neither
/// `/proc/meminfo` nor `GlobalMemoryStatusEx`, or when the source is
/// unreadable.
pub fn read_memory_pressure() -> Option<MemoryPressure> {
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
let mem_available_kb = parse_meminfo_available_kb(&content)?;
let profile = memory_profile();
Some(pressure_from_figures(mem_available_kb, profile.mem_total_kb))
#[cfg(windows)]
{
let (_total_kb, avail_kb) = global_memory_status_kb()?;
let profile = memory_profile();
return Some(pressure_from_figures(avail_kb, profile.mem_total_kb));
}
#[cfg(not(windows))]
{
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
let mem_available_kb = parse_meminfo_available_kb(&content)?;
let profile = memory_profile();
Some(pressure_from_figures(mem_available_kb, profile.mem_total_kb))
}
}
/// Pure detection given `/proc/meminfo` content. Falls back to Normal
@@ -164,6 +205,17 @@ pub fn detect_profile_from_meminfo(content: &str) -> MemoryProfile {
/// profile on platforms without `/proc/meminfo` (macOS, Windows) or when
/// the file is unreadable for any reason.
fn detect_profile() -> MemoryProfile {
#[cfg(windows)]
{
// Same Normal-fallback contract as the meminfo path: a probe we could
// not run must never throttle the machine.
return match global_memory_status_kb() {
Some((total_kb, _avail_kb)) => MemoryProfile::from_total_kb(total_kb),
None => MemoryProfile::from_total_kb(u64::MAX),
};
}
#[cfg(not(windows))]
match std::fs::read_to_string("/proc/meminfo") {
Ok(content) => detect_profile_from_meminfo(&content),
Err(_) => MemoryProfile::from_total_kb(u64::MAX),
+16 -1
View File
@@ -16,7 +16,6 @@ aes-gcm = "0.10"
sha2 = "0.11"
rand = "0.10"
dirs = "6"
keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] }
[dev-dependencies]
tempfile = "3"
@@ -29,3 +28,19 @@ tempfile = "3"
# running" and dead AT-SPI accessibility. async-io futures are runtime-
# agnostic, so our portal call still awaits fine inside tokio.
ashpd = { version = "0.13", default-features = false, features = ["async-io", "secret"] }
# OS keyring backends, per target. keyring 3 has NO default backend feature:
# without the per-platform one it silently exports the in-memory `mock`
# (keyring-3.6.3/src/lib.rs:274-298), whose writes succeed and whose contents
# die with the process. An unconditional feature list also meant this crate
# only resolved correctly because Cargo unified features with qbz-secrets.
# Same shape as crates/qbz-secrets/Cargo.toml.
keyring = { version = "3", default-features = false, features = [
"async-secret-service",
"crypto-rust",
] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", default-features = false, features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", default-features = false, features = ["windows-native"] }
+24
View File
@@ -261,6 +261,30 @@ fn load_or_create_machine_id_fallback_at(root: &Path) -> Result<Vec<u8>, String>
/// when none of `/etc/machine-id`, `$HOSTNAME`, `$USER` yields a value (the
/// caller then falls back to a persisted random id under its config root).
fn machine_id_stable_source() -> Option<Vec<u8>> {
// WINDOWS FIRST, and deliberately before the two env-var fallbacks below.
//
// Windows has no /etc/machine-id, so before this arm existed the chain fell
// through to `HOSTNAME` and then `USER` -- both of which are UNIX names that
// Windows itself never sets, but that an MSYS/Git-Bash environment may.
// Measured on BFCPC-WIN 2026-08-27: Git-Bash sets HOSTNAME as a shell
// variable it does not export (a child process reads None) and leaves USER
// empty, so the chain returned None and the persisted random fallback kept
// the key stable. Stable BY ACCIDENT: any environment that exports either
// name would silently derive a different key, and a key that changes is a
// credentials file that no longer decrypts.
//
// COMPUTERNAME is set by Windows itself, for every process, however the
// app was launched. Owner-approved 2026-08-27, accepting the one-time
// re-authentication that moving the key costs.
#[cfg(windows)]
{
if let Ok(name) = std::env::var("COMPUTERNAME") {
if !name.trim().is_empty() {
return Some(name.as_bytes().to_vec());
}
}
}
// Try /etc/machine-id first (Linux)
if let Ok(id) = fs::read_to_string("/etc/machine-id") {
let trimmed = id.trim();
@@ -6042,3 +6042,12 @@ msgstr "Dieses Album enthält keine Titel."
msgid "Reactive Rings"
msgstr "Reaktive Ringe"
msgid "Windows — yes, but read this first"
msgstr "Windows — ja, aber lies zuerst das hier"
msgid "Don't show this again"
msgstr "Nicht mehr anzeigen"
msgid "I understand"
msgstr "Ich verstehe"
@@ -6048,3 +6048,12 @@ msgstr "Este álbum no tiene pistas."
msgid "Reactive Rings"
msgstr "Anillos reactivos"
msgid "Windows — yes, but read this first"
msgstr "Windows — sí, pero lee esto primero"
msgid "Don't show this again"
msgstr "No volver a mostrar"
msgid "I understand"
msgstr "Entiendo"
@@ -6040,3 +6040,12 @@ msgstr "Cet album ne contient aucun titre."
msgid "Reactive Rings"
msgstr "Anneaux réactifs"
msgid "Windows — yes, but read this first"
msgstr "Windows — oui, mais lisez d'abord ceci"
msgid "Don't show this again"
msgstr "Ne plus afficher"
msgid "I understand"
msgstr "Je comprends"
@@ -6024,3 +6024,12 @@ msgstr "このアルバムには曲がありません。"
msgid "Reactive Rings"
msgstr "リアクティブリング"
msgid "Windows — yes, but read this first"
msgstr "Windows — はい、ただしまずこれをお読みください"
msgid "Don't show this again"
msgstr "今後表示しない"
msgid "I understand"
msgstr "理解しました"
@@ -6030,3 +6030,12 @@ msgstr "Dit album bevat geen nummers."
msgid "Reactive Rings"
msgstr "Reactieve ringen"
msgid "Windows — yes, but read this first"
msgstr "Windows — ja, maar lees dit eerst"
msgid "Don't show this again"
msgstr "Niet meer weergeven"
msgid "I understand"
msgstr "Ik begrijp het"
@@ -6040,3 +6040,12 @@ msgstr "Este álbum não tem faixas."
msgid "Reactive Rings"
msgstr "Anéis reativos"
msgid "Windows — yes, but read this first"
msgstr "Windows — sim, mas leia isto primeiro"
msgid "Don't show this again"
msgstr "Não mostrar novamente"
msgid "I understand"
msgstr "Eu entendo"
@@ -6060,3 +6060,12 @@ msgstr "В этом альбоме нет треков."
msgid "Reactive Rings"
msgstr "Реактивные кольца"
msgid "Windows — yes, but read this first"
msgstr "Windows — да, но сначала прочтите это"
msgid "Don't show this again"
msgstr "Больше не показывать"
msgid "I understand"
msgstr "Я понимаю"
+10
View File
@@ -14,5 +14,15 @@ thiserror.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", features = ["fs"] }
[target.'cfg(windows)'.dependencies]
# GetDiskFreeSpaceExW for the free-space preflight; LockFileEx (+ OVERLAPPED,
# hence Win32_System_IO) for the bootstrap lock. 0.61 is already in the
# workspace lock, so this pulls nothing new.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_IO",
] }
[dev-dependencies]
tempfile = "3"
+68 -1
View File
@@ -472,6 +472,41 @@ pub(crate) fn acquire_lock(path: &Path) -> Result<File> {
}
})?;
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
use windows_sys::Win32::Storage::FileSystem::{
LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
};
let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED =
unsafe { std::mem::zeroed() };
// SAFETY: `file` is open, so its handle is valid for the call; the
// zeroed OVERLAPPED lives for the whole call; locking 0..u64::MAX is
// the documented "lock the entire file" idiom. The lock is released
// when `file` drops and the handle closes, matching flock().
let ok = unsafe {
LockFileEx(
file.as_raw_handle() as _,
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
0,
u32::MAX,
u32::MAX,
&mut overlapped,
)
};
if ok == 0 {
let error = std::io::Error::last_os_error();
return Err(
if error.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
CatalogError::BootstrapBusy
} else {
CatalogError::Io(error)
},
);
}
}
Ok(file)
}
@@ -512,7 +547,39 @@ fn filesystem_available_bytes(path: &Path) -> Result<u64> {
Ok(status.f_bavail.saturating_mul(status.f_frsize))
}
#[cfg(not(unix))]
#[cfg(windows)]
fn filesystem_available_bytes(path: &Path) -> Result<u64> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
// GetDiskFreeSpaceExW wants a path that exists. `available_bytes()` already
// hands us the nearest existing ancestor, but keep the walk local so a
// future caller cannot turn this into ERROR_PATH_NOT_FOUND on first run.
let probe = nearest_existing_ancestor(path).unwrap_or(path);
let wide: Vec<u16> = probe
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut free_to_caller: u64 = 0;
// SAFETY: `wide` is NUL-terminated and outlives the call; `free_to_caller`
// is a valid u64 slot; the remaining two out-params are documented as
// optional and are passed as null.
let ok = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut free_to_caller,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(CatalogError::Io(std::io::Error::last_os_error()));
}
Ok(free_to_caller)
}
#[cfg(not(any(unix, windows)))]
fn filesystem_available_bytes(_path: &Path) -> Result<u64> {
Err(CatalogError::InvalidInput(
"filesystem free-space preflight is unsupported on this platform".to_string(),
+9
View File
@@ -8,6 +8,10 @@ description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on
[dependencies]
log = { workspace = true }
# fs_url::local_path -- the ONE file-URL helper (W2). SMTC needs a native
# Windows path where MPRIS needs a file:// URL, and hand-rolling that
# conversion is exactly the bug class that helper exists to prevent.
qbz-models = { path = "../qbz-models" }
# Notifications (all platforms): the async entry point spawns the HTTP download
# + image resize on `spawn_blocking`, so a tokio runtime must be present.
tokio = { workspace = true }
@@ -27,6 +31,11 @@ urlencoding = "2"
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
# which is the only way GNOME Shell resolves the app icon. souvlaki never sets
# it. tokio feature so the server shares the app's tokio runtime.
[target.'cfg(target_os = "windows")'.dependencies]
# SetThreadExecutionState: keep the machine awake while a track plays.
# 0.61 is already in the workspace lock.
windows-sys = { version = "0.61", features = ["Win32_System_Power"] }
[target.'cfg(target_os = "linux")'.dependencies]
mpris-server = "0.8"
async-channel = "2"
+31 -2
View File
@@ -33,22 +33,51 @@ mod platform;
/// **macOS:** souvlaki's command callbacks fire on the app run loop, so this is
/// safe to call from any thread, but the run loop (Slint's winit loop) must be
/// running for inbound events to arrive.
/// Keep the machine awake while playing. No-op off Windows; see
/// `platform::set_sleep_inhibit` for why it is not a handle method.
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub use platform::set_sleep_inhibit;
/// Keep the machine awake while playing. No-op on this platform.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub fn set_sleep_inhibit(_playing: bool) {}
/// The platform window identity the OS integration needs.
///
/// Windows SMTC is registered against a window: souvlaki's backend calls
/// `config.hwnd.expect(..)`, so a missing handle is a PANIC, not a graceful
/// degrade. Linux and macOS key off the D-Bus name and the app bundle
/// respectively and ignore this entirely, which is why `default()` is the
/// right thing to pass there and from headless callers.
#[derive(Clone, Copy, Debug, Default)]
pub struct NativeWindow {
pub hwnd: Option<*mut core::ffi::c_void>,
}
// SAFETY: an HWND is an opaque process-wide identifier, not a pointer that is
// dereferenced here. It is read once by souvlaki on the thread that receives
// it. Windows itself allows a window handle to be named from any thread; what
// is thread-affine is the window's message loop, which this never touches.
unsafe impl Send for NativeWindow {}
pub fn spawn(
on_event: impl Fn(MediaEvent) + Send + Sync + 'static,
native: NativeWindow,
) -> Option<Box<dyn MediaIntegration>> {
let cb: Arc<dyn Fn(MediaEvent) + Send + Sync> = Arc::new(on_event);
#[cfg(target_os = "linux")]
{
let _ = native;
return linux::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
return platform::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
return platform::spawn(cb, native).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = cb;
let _ = (cb, native);
None
}
}
+61 -6
View File
@@ -100,9 +100,9 @@ fn build_body(meta: &NotificationMeta) -> String {
lines.join("\n")
}
// --- artwork cache (Linux + macOS) ------------------------------------------
// --- artwork cache (Linux + macOS + Windows) ------------------------------------------
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
fn artwork_cache_dir() -> Result<PathBuf, String> {
let dir = dirs::cache_dir()
.ok_or_else(|| "Could not find cache directory".to_string())?
@@ -112,7 +112,7 @@ fn artwork_cache_dir() -> Result<PathBuf, String> {
Ok(dir)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
fn resolve_local_artwork(url: &str) -> Option<PathBuf> {
if let Some(path) = url.strip_prefix("file://") {
// file:// URLs built with url::Url::from_file_path (e.g. the shared
@@ -122,6 +122,29 @@ fn resolve_local_artwork(url: &str) -> Option<PathBuf> {
let decoded = urlencoding::decode(path)
.map(|c| c.into_owned())
.unwrap_or_else(|_| path.to_string());
// `file:///C:/x` leaves `/C:/x` here: the empty authority's slash, in
// front of a drive letter. PathBuf keeps it and the file never
// resolves, so the toast silently shows no cover. Forward slashes
// after that are fine -- Windows accepts them.
//
// Additive: Linux and macOS never match this shape, and their full
// percent-decode above is left exactly as it was. It has to stay
// full: art_url reaches here from BOTH url::Url::from_file_path
// (which escapes spaces as %20) and fs_url::file_url (which escapes
// only % # ?), and only the wider decode reads both.
#[cfg(target_os = "windows")]
{
let b = decoded.as_bytes();
if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
return Some(PathBuf::from(&decoded[1..]));
}
// A non-empty authority is a UNC server: `file://nas/share/x`
// names `\\nas\share\x`. Without this it stays RELATIVE and
// resolves against the process working directory.
if !decoded.starts_with('/') && !decoded.is_empty() {
return Some(PathBuf::from(format!("//{decoded}")));
}
}
return Some(PathBuf::from(decoded));
}
if let Some(path) = url.strip_prefix("asset://localhost/") {
@@ -133,7 +156,7 @@ fn resolve_local_artwork(url: &str) -> Option<PathBuf> {
/// Shared blocking HTTP client (a fresh client per track leaks an fd → EMFILE
/// over a long session — same reasoning as the Tauri image cache).
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
fn http_client() -> &'static reqwest::blocking::Client {
static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
@@ -149,7 +172,7 @@ fn http_client() -> &'static reqwest::blocking::Client {
/// `offline` = local paths + md5 cache hits only, never the HTTP download —
/// the verdict is injected by the caller so this crate stays frontend-agnostic
/// (no dependency on the app's offline-mode engine).
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
fn cache_artwork(url: &str, offline: bool) -> Result<PathBuf, String> {
use md5::{Digest, Md5};
use std::io::Write;
@@ -340,7 +363,39 @@ pub async fn show_track_notification(meta: NotificationMeta, offline: bool) {
.await;
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[cfg(target_os = "windows")]
{
let _ = tokio::task::spawn_blocking(move || {
let artwork_path = meta.art_url.as_deref().and_then(|url| {
match cache_artwork(url, offline) {
Ok(path) => Some(path),
Err(e) => {
log::debug!("[notify] could not cache artwork: {e}");
None
}
}
});
let mut notification = notify_rust::Notification::new();
// app_id MUST match the AUMID set in main() and registered by the
// MSI, or Windows drops the toast without a word. notify-rust's
// Windows arm forwards summary/body/app_id/image and SILENTLY
// ignores `actions` (src/windows.rs), so no buttons are attempted
// here -- adding them would look implemented and do nothing.
notification
.summary(&meta.title)
.body(&body)
.app_id("com.blitzfc.qbz");
if let Some(path) = artwork_path.as_ref().and_then(|p| p.to_str()) {
notification.image_path(path);
}
if let Err(e) = notification.show() {
log::warn!("[notify] Windows toast failed: {e}");
}
})
.await;
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = body;
let _ = offline;
+277 -9
View File
@@ -8,18 +8,109 @@
use std::sync::{Arc, Mutex};
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
SeekDirection,
};
use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition};
use souvlaki::{MediaControlEvent, PlatformConfig, SeekDirection};
use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
use crate::NativeWindow;
/// Default step for a magnitude-less MPRIS-style `Seek` (5 seconds, micros).
const SEEK_STEP_MICROS: i64 = 5_000_000;
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
/// Keeps Windows from sleeping while a track plays.
///
/// `SetThreadExecutionState` sets state on the CALLING THREAD, and this
/// module's own contract says outbound `set_playback` is "free from any
/// thread" -- in practice the mpris-server thread, a tokio worker, or the Qt
/// GUI thread, whichever pushed the update. Calling the API from there would
/// pin the flag to a thread that may be a pool worker with a short life, and
/// the request would silently evaporate with it.
///
/// So the state is owned by ONE thread that lives as long as the process, and
/// callers only send it a bool. Windows drops every request a thread made when
/// that thread exits, which is exactly why this thread never does.
#[cfg(target_os = "windows")]
mod sleep {
use std::sync::mpsc::{channel, Sender};
use std::sync::OnceLock;
fn worker() -> &'static Sender<bool> {
static TX: OnceLock<Sender<bool>> = OnceLock::new();
TX.get_or_init(|| {
let (tx, rx) = channel::<bool>();
std::thread::Builder::new()
.name("qbz-sleep-inhibit".to_string())
.spawn(move || {
use windows_sys::Win32::System::Power::{
SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED,
};
let mut held = false;
while let Ok(playing) = rx.recv() {
if playing == held {
continue;
}
// ES_SYSTEM_REQUIRED without ES_DISPLAY_REQUIRED: an
// audio player must keep the MACHINE awake, not the
// screen. No ES_AWAYMODE_REQUIRED either -- that is for
// media-centre appliances and it suppresses the sleep
// the user asked for.
let flags = if playing {
ES_CONTINUOUS | ES_SYSTEM_REQUIRED
} else {
ES_CONTINUOUS
};
// SAFETY: a plain flags call with no pointers. Always on
// this thread, which is the whole point of the module.
let previous = unsafe { SetThreadExecutionState(flags) };
if previous == 0 {
// The call FAILED (it returns the previous state,
// or zero). Leave `held` alone: recording the new
// value here would make every later message with
// the same bool a no-op, so a failed RELEASE would
// pin the machine awake for the rest of the
// session with nothing left to undo it.
log::warn!(
"[inhibit] SetThreadExecutionState failed; execution state unchanged"
);
continue;
}
held = playing;
log::debug!(
"[inhibit] Windows execution state -> {}",
if playing { "system required" } else { "continuous" }
);
}
})
.expect("spawn sleep-inhibit thread");
tx
})
}
pub(super) fn set_playing(playing: bool) {
let _ = worker().send(playing);
}
}
/// Keep the machine awake while playing, INDEPENDENTLY of whether the media
/// integration started.
///
/// Deliberately a free function and not a `PlatformHandle` method. Hanging it
/// off the handle tied it to SMTC: every caller returns early when
/// `handle()` is `None`, so a failed souvlaki init or an HWND that never
/// arrived would leave playback working and the machine falling asleep
/// mid-track -- two unrelated features failing together for no reason.
///
/// A no-op everywhere but Windows: Linux hangs its logind inhibitor off the
/// MPRIS update (`inhibit.rs`) and macOS needs none.
pub fn set_sleep_inhibit(playing: bool) {
#[cfg(target_os = "windows")]
sleep::set_playing(playing);
#[cfg(not(target_os = "windows"))]
let _ = playing;
}
pub struct PlatformHandle {
controls: Arc<Mutex<Option<MediaControls>>>,
}
@@ -28,12 +119,19 @@ impl MediaIntegration for PlatformHandle {
fn set_metadata(&self, meta: &TrackMeta) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(c) = guard.as_mut() {
// SMTC wants a NATIVE path where MPRIS wants a URL; see
// `cover_for_souvlaki`. Bound to a local so the &str borrowed
// into MediaMetadata outlives the call.
let cover = meta
.art_url
.as_deref()
.map(|u| cover_for_souvlaki(u, cfg!(target_os = "windows")));
let md = MediaMetadata {
title: Some(meta.title.as_str()),
artist: Some(meta.artist.as_str()),
album: Some(meta.album.as_str()),
duration: meta.duration,
cover_url: meta.art_url.as_deref(),
cover_url: cover.as_deref(),
};
let _ = c.set_metadata(md);
}
@@ -85,13 +183,96 @@ fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
})
}
pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> {
/// Convert a cover-art URL into the exact string souvlaki's Windows arm can
/// open.
///
/// souvlaki 0.8.3 (`src/platform/windows/mod.rs:192-203`) branches like this:
///
/// ```text
/// if url.starts_with("file://") {
/// let path = url.trim_start_matches("file://");
/// StorageFile::GetFileFromPathAsync(path) // wants C:\x or \\nas\x
/// } else {
/// RandomAccessStreamReference::CreateFromUri(url)
/// }
/// ```
///
/// So `file://` is the DISCRIMINATOR and must survive, while everything after
/// it has to be a native Windows path. Two ways to get this wrong, and the
/// first draft of this function managed one of them:
///
/// - Hand over `file:///C:/x.jpg` and souvlaki trims seven characters, leaving
/// `/C:/x.jpg` -- a leading slash before a drive letter, which never opens.
/// - Hand over a bare `C:\x.jpg` and it no longer starts with `file://`, so it
/// takes the REMOTE branch and `Uri::CreateUri` fails on it.
///
/// The answer is the hybrid `file://C:\x.jpg`: prefix kept, remainder native.
///
/// UNC is a real Windows path too. `file://nas/music/a.jpg` names
/// `\\nas\music\a.jpg`, and leaving it alone would hand souvlaki the
/// relative `nas/music/a.jpg`.
///
/// The percent-decode is FULL and applied ONCE. `art_url` reaches this from
/// two producers -- `url::Url::from_file_path`, which escapes a space as
/// `%20`, and `fs_url::file_url`, which escapes only `%`, `#` and `?` -- and a
/// single full decode is correct for both. `fs_url::local_path` is NOT used
/// here for that reason: its deliberate three-escape decode would leave a
/// `%20` from the other producer intact.
pub(crate) fn cover_for_souvlaki(url: &str, windows: bool) -> String {
if !windows {
return url.to_string();
}
let Some(rest) = url.strip_prefix("file://") else {
// http(s) and anything else: souvlaki's Uri branch is the right one.
return url.to_string();
};
let decoded = urlencoding::decode(rest)
.map(|c| c.into_owned())
.unwrap_or_else(|_| rest.to_string());
let b = decoded.as_bytes();
let native = if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
// `file:///C:/x` -> the empty authority's slash sits in front of the
// drive letter; drop it.
decoded[1..].to_string()
} else if !decoded.starts_with('/') && !decoded.is_empty() {
// A non-empty authority is a UNC server: `file://nas/share/x`.
format!("//{decoded}")
} else {
// A POSIX absolute path. Not a Windows path at all, and nothing here
// can make it one -- hand it back untouched rather than inventing
// `\home\...`.
return url.to_string();
};
format!("file://{}", native.replace('/', "\\"))
}
pub fn spawn(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> {
// souvlaki's Windows backend does NOT degrade gracefully without an HWND:
// `config.hwnd.expect(...)` at souvlaki-0.8.3/src/platform/windows/mod.rs:57-59
// PANICS. The guard therefore stays even now that a handle IS wired:
// a missing integration is a missing feature, a panic inside a tokio task
// is a dead session. The caller reads the HWND on the GUI thread after the
// window is shown, so None here means it asked too early.
#[cfg(target_os = "windows")]
if native.hwnd.is_none() {
log::warn!(
"[media-controls] SMTC disabled: no top-level window yet (init ran before it was shown)"
);
return None;
}
spawn_native(on_event, native)
}
fn spawn_native(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> {
let config = PlatformConfig {
dbus_name: "com.blitzfc.qbz",
display_name: "QBZ",
// macOS: unused. Windows SMTC needs the window HWND; not shipped/tested,
// so left None (init may fail there → None handle, no media controls).
hwnd: None,
// macOS ignores it; Windows registers SMTC against this window.
hwnd: native.hwnd,
};
let mut controls = match MediaControls::new(config) {
@@ -116,3 +297,90 @@ pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> {
controls: Arc::new(Mutex::new(Some(controls))),
})
}
#[cfg(test)]
mod tests {
use super::cover_for_souvlaki;
#[test]
fn windows_keeps_the_file_prefix_and_makes_the_rest_native() {
// The prefix is souvlaki's discriminator: drop it and the string takes
// the remote-URI branch. Keep it, and what it trims must be a real
// Windows path.
assert_eq!(
cover_for_souvlaki("file:///C:/Users/v/c.jpg", true),
"file://C:\\Users\\v\\c.jpg"
);
}
#[test]
fn non_windows_is_byte_identical() {
assert_eq!(
cover_for_souvlaki("file:///home/v/c.jpg", false),
"file:///home/v/c.jpg"
);
assert_eq!(
cover_for_souvlaki("file:///C:/Users/v/c.jpg", false),
"file:///C:/Users/v/c.jpg"
);
}
#[test]
fn remote_urls_are_never_touched() {
assert_eq!(cover_for_souvlaki("https://x/y.jpg", true), "https://x/y.jpg");
}
#[test]
fn a_unc_url_becomes_a_unc_path() {
// `file://nas/music/a.jpg` is a real Windows path. Left alone, souvlaki
// would trim it to the RELATIVE `nas/music/a.jpg`.
assert_eq!(
cover_for_souvlaki("file://nas/music/a.jpg", true),
"file://\\\\nas\\music\\a.jpg"
);
}
#[test]
fn a_posix_path_is_left_alone_even_on_windows() {
// No drive, no authority: nothing here can turn it into a Windows path.
assert_eq!(
cover_for_souvlaki("file:///home/v/c.jpg", true),
"file:///home/v/c.jpg"
);
}
#[test]
fn both_producers_percent_decode_the_same_way() {
// fs_url::file_url leaves a space LITERAL...
assert_eq!(
cover_for_souvlaki("file:///C:/My Music/a b.jpg", true),
"file://C:\\My Music\\a b.jpg"
);
// ...while url::Url::from_file_path escapes it as %20. One full decode
// is right for both.
assert_eq!(
cover_for_souvlaki("file:///C:/My%20Music/a.jpg", true),
"file://C:\\My Music\\a.jpg"
);
}
#[test]
fn the_escapes_that_matter_are_undone() {
// A # in a filename reaches SMTC as %23 otherwise, and the cover
// silently never loads.
assert_eq!(
cover_for_souvlaki("file:///C:/m/a%23b%3Fc.jpg", true),
"file://C:\\m\\a#b?c.jpg"
);
}
#[test]
fn a_literal_percent_twenty_is_not_double_decoded() {
// A filename that really contains "%20" is escaped to %2520 by either
// producer; ONE decode must give back "%20", not a space.
assert_eq!(
cover_for_souvlaki("file:///C:/m/a%2520b.jpg", true),
"file://C:\\m\\a%20b.jpg"
);
}
}
+247
View File
@@ -0,0 +1,247 @@
//! `file://` URLs and "is this an absolute local path" — ONE definition.
//!
//! Sites used to build `format!("file://{p}")` by hand and to test
//! `starts_with('/')` for "absolute path". Both are Unix-only:
//! `file://C:\Users\…` parses `C:` as a URL **host**, and `C:\…` does not
//! start with `/`. Windows lost every local cover, the folder tree and the
//! runtime-tinted icons — silently, because a URL that resolves to nothing
//! renders as an empty rectangle, not as an error. Everything routes through
//! here now.
//!
//! # Two ambiguities no string algorithm can resolve
//!
//! A backslash is a legal filename character on Unix, and so is a colon. So
//! `C:\cover.jpg` and `/C:/music` are each simultaneously a valid Windows path
//! and a valid (if pathological) Unix one. This module reads them as Windows,
//! which costs a Unix user only if their path's FIRST component is a single
//! letter followed by a colon. The alternative — deciding by host — would make
//! the tests pass on the machine that runs them and fail on the one that
//! ships, which is the failure mode this module exists to end.
/// Absolute local path, by SHAPE (so the tests run on every host):
/// `/…` (POSIX), `X:\…` / `X:/…` (drive), `\\server\share…` (UNC).
///
/// Shape, not `Path::is_absolute`: that answers for the HOST it runs on, so a
/// Linux CI run would call `C:\x` relative and the Windows-only regression
/// would pass every test on the machine that runs them.
pub fn is_local_abs_path(s: &str) -> bool {
s.starts_with('/') || is_drive_path(s) || s.starts_with("\\\\")
}
/// `X:\…` or `X:/…`. A separator is REQUIRED: `C:cover.jpg` is drive-RELATIVE
/// on Windows and an ordinary relative filename on Unix, so it is neither an
/// absolute path nor something to rewrite.
fn is_drive_path(s: &str) -> bool {
let b = s.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
}
/// Undo Windows extended-length ("verbatim") syntax.
///
/// `std::fs::canonicalize` RETURNS this form on Windows — documented, not
/// incidental — and `qbz-library/src/scan.rs` stores exactly that string for
/// every scanned file. Left alone, `\\?\C:\Music\cover.jpg` reads as a UNC
/// path whose server is `?`, and the URL comes out `file://%3F/C:/Music/…`:
/// the whole Local Library would have lost its covers on Windows.
fn undo_verbatim(path: &str) -> String {
match path.strip_prefix("\\\\?\\") {
// `\\?\UNC\server\share` is the verbatim spelling of `\\server\share`.
Some(rest) => match rest.strip_prefix("UNC\\") {
Some(unc) => format!("\\\\{unc}"),
None => rest.to_string(),
},
None => path.to_string(),
}
}
/// `file://` URL for a raw filesystem path. Idempotent for inputs that already
/// carry the scheme.
///
/// Qt's `QUrl` parses `#` as a fragment and `?` as a query, so a cover under
/// `…/Album #1/cover.jpg` resolves to nothing when concatenated raw.
/// Percent-encode exactly those two plus `%` itself (first, or the escapes we
/// add would be double-decoded). Spaces are left alone: `QUrl` accepts them.
///
/// Shapes:
/// * `/home/v/x` → `file:///home/v/x`
/// * `C:\Users\v\x` → `file:///C:/Users/v/x` (empty authority; what `QUrl` and
/// WinRT both accept)
/// * `\\nas\music\x` → `file://nas/music/x` (the server IS the authority, and
/// `QUrl::toLocalFile` restores `//nas/music/x` from exactly this)
/// * `rel/x` → `file://rel/x`, byte-identical to the hand-rolled code this
/// replaced. A relative path was never a valid `file://` URL; it is left
/// alone rather than "improved", because Linux behaviour does not move in a
/// Windows port.
pub fn file_url(path: &str) -> String {
if path.starts_with("file://") {
return path.to_string();
}
let path = undo_verbatim(path);
// Backslash-to-slash ONLY for a Windows-shaped input. A backslash is a
// legal filename character on Unix, so rewriting it unconditionally would
// point `/home/v/AC\DC/cover.jpg` at a directory that does not exist.
let win_shaped = is_drive_path(&path) || path.starts_with("\\\\");
let normalised = if win_shaped {
path.replace('\\', "/")
} else {
path
};
let mut out = String::with_capacity(normalised.len() + 8);
out.push_str("file://");
let body: &str = if let Some(unc) = normalised.strip_prefix("//") {
// UNC: the server becomes the authority.
unc
} else if win_shaped {
// A drive path needs the third slash for the empty authority.
out.push('/');
&normalised
} else {
// POSIX absolute already carries its own leading slash; a relative
// path keeps the shape the old code produced.
&normalised
};
for ch in body.chars() {
match ch {
'%' => out.push_str("%25"),
'#' => out.push_str("%23"),
'?' => out.push_str("%3F"),
_ => out.push(ch),
}
}
out
}
/// The inverse of [`file_url`]: a `file://` URL back to a filesystem path.
///
/// `trim_start_matches("file://")` is NOT the inverse — it leaves the three
/// percent-escapes in place, drops the `//` a UNC authority stands for, and on
/// Windows leaves a `/` before the drive letter. Each of those names a file
/// that does not exist, and the caller (a tint decode, a copy, an
/// `Image.source`) then fails silently.
pub fn local_path(url: &str) -> String {
let Some(rest) = url.strip_prefix("file://") else {
return url.to_string();
};
let raw = rest
.replace("%23", "#")
.replace("%3F", "?")
.replace("%25", "%");
let b = raw.as_bytes();
// `file:///C:/x` → rest `/C:/x` → `C:/x`.
if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
return raw[1..].to_string();
}
// `file:///home/x` → rest `/home/x`: an empty authority, already a path.
if raw.starts_with('/') || raw.is_empty() {
return raw;
}
// A non-empty authority is a UNC server: `file://nas/music/x` names
// `//nas/music/x`, which is what `QUrl::toLocalFile` returns too.
format!("//{raw}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn posix_paths_round_trip_and_escape() {
assert_eq!(
file_url("/home/v/Album #1/cover?.jpg"),
"file:///home/v/Album %231/cover%3F.jpg"
);
assert_eq!(
local_path("file:///home/v/Album%20%231/c%25.jpg"),
"/home/v/Album%20#1/c%.jpg"
);
assert_eq!(file_url("file:///already"), "file:///already");
}
#[test]
fn drive_paths_get_the_empty_authority_form() {
assert_eq!(
file_url("C:\\Users\\v\\cover.jpg"),
"file:///C:/Users/v/cover.jpg"
);
assert_eq!(
file_url("C:/Users/v/cover.jpg"),
"file:///C:/Users/v/cover.jpg"
);
assert_eq!(
local_path("file:///C:/Users/v/cover.jpg"),
"C:/Users/v/cover.jpg"
);
}
#[test]
fn drive_paths_round_trip_through_both_directions() {
// The regression that started this: a tinted icon directory.
let p = "C:\\Users\\blitz\\AppData\\Local\\qbz\\icon-tints\\4285f4";
let u = file_url(p);
assert_eq!(
u,
"file:///C:/Users/blitz/AppData/Local/qbz/icon-tints/4285f4"
);
assert_eq!(
local_path(&u),
"C:/Users/blitz/AppData/Local/qbz/icon-tints/4285f4"
);
assert_eq!(file_url(&u), u, "file_url must be idempotent");
}
/// `std::fs::canonicalize` returns this shape on Windows and
/// `qbz-library/src/scan.rs` stores it for every scanned file. Read as UNC
/// it emits `file://%3F/C:/…` and the Local Library shows no covers at all.
#[test]
fn verbatim_extended_length_paths_are_undone() {
assert_eq!(
file_url("\\\\?\\C:\\Music\\Album\\cover.jpg"),
"file:///C:/Music/Album/cover.jpg"
);
assert_eq!(
file_url("\\\\?\\UNC\\nas\\music\\cover.jpg"),
"file://nas/music/cover.jpg"
);
}
#[test]
fn unc_paths_put_the_server_in_the_authority_and_come_back() {
let u = file_url("\\\\nas\\music\\cover.jpg");
assert_eq!(u, "file://nas/music/cover.jpg");
// Not `nas/music/cover.jpg`: that names nothing. Same as
// QUrl::toLocalFile.
assert_eq!(local_path(&u), "//nas/music/cover.jpg");
}
#[test]
fn a_backslash_in_a_unix_filename_is_not_a_separator() {
// Legal on Unix; rewriting it would name a path that does not exist.
assert_eq!(
file_url("/home/v/AC\\DC/cover.jpg"),
"file:///home/v/AC\\DC/cover.jpg"
);
}
/// A relative path is not a valid `file://` URL in any spelling. What
/// matters is that Linux gets the SAME bytes the hand-rolled code gave it.
#[test]
fn relative_paths_keep_the_shape_the_old_code_produced() {
assert_eq!(file_url("covers/x.jpg"), "file://covers/x.jpg");
// Drive-RELATIVE is relative too, on both platforms.
assert_eq!(file_url("C:cover.jpg"), "file://C:cover.jpg");
}
#[test]
fn absolute_path_shape_is_host_independent() {
assert!(is_local_abs_path("/x"));
assert!(is_local_abs_path("C:\\x"));
assert!(is_local_abs_path("c:/x"));
assert!(is_local_abs_path("\\\\nas\\x"));
assert!(!is_local_abs_path("x/y"));
assert!(!is_local_abs_path("https://x"));
assert!(!is_local_abs_path("Album|Artist"));
assert!(!is_local_abs_path("C:x")); // drive-relative is not absolute
}
}
+1
View File
@@ -31,6 +31,7 @@
pub mod error;
pub mod events;
pub mod fs_url;
pub mod lenient;
pub mod mixtape;
pub mod playback;
+100 -22
View File
@@ -563,8 +563,14 @@ enum StreamType {
#[cfg(target_os = "macos")]
exclusive_guard: Option<qbz_audio::CoreAudioExclusiveGuard>,
},
#[cfg(target_os = "linux")]
AlsaDirect(Arc<qbz_audio::AlsaDirectStream>),
/// Any bit-perfect DIRECT sink: ALSA hw: on Linux, WASAPI exclusive on
/// Windows. One variant rather than one per platform, because everything
/// downstream treats them identically - see `qbz_audio::backend::DirectSink`.
///
/// NOT cfg-gated: gating it to Linux is what made the Windows compiler
/// unable to check any of the arms that handle it, which is the whole
/// blind spot this port has to work around.
Direct(Arc<dyn qbz_audio::backend::DirectSink>),
/// Native JACK output (#263 Tier 3). QBZ as a JACK client with stable ports;
/// NOT bit-perfect (resampled to the graph rate).
#[cfg(target_os = "linux")]
@@ -900,6 +906,57 @@ fn try_init_stream_with_backend(
skip_sink_switch: audio_settings.skip_sink_switch,
};
// WASAPI EXCLUSIVE (Windows) - the bit-perfect path, tried before the
// shared CPAL stream exactly as ALSA Direct is on Linux.
//
// The device id is the WASAPI ENDPOINT ID, which is what the enumeration
// already publishes: Windows hands several endpoints the same friendly
// name, so the name cannot identify one. Without a chosen device there is
// nothing to open exclusively - falling through to shared is the honest
// answer, not picking the default DAC behind the user's back.
#[cfg(windows)]
if backend_type == AudioBackendType::WasapiExclusive {
match config.device_id.as_ref() {
None => {
log::info!(
"[WASAPI] Exclusive mode selected with no device chosen; using the shared stream. Pick an output device to get bit-perfect playback."
);
}
Some(endpoint_id) => {
use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming};
match WasapiDirectStream::new(
endpoint_id,
sample_rate,
config.channels,
WasapiTiming::Events,
) {
Ok(stream) => {
let mode = stream.bit_perfect_mode();
let info = stream.open_info();
log::info!(
"[WASAPI] Exclusive stream open: {} @ {} Hz, {:?}, mode {:?}",
info.endpoint_name,
info.rate,
info.rung,
mode
);
state.set_bit_perfect_mode(Some(mode));
return Some(Ok(StreamType::Direct(Arc::new(stream))));
}
Err(e) => {
// NOT fatal: a device that refuses this rate in
// exclusive mode still plays through the shared path,
// and silence would be a worse answer than a
// resampled track.
log::warn!(
"[WASAPI] Exclusive open failed ({e}); falling back to the shared stream"
);
}
}
}
}
}
// For ALSA backend with hw: devices, try direct ALSA first (Linux only)
#[cfg(target_os = "linux")]
if backend_type == AudioBackendType::Alsa {
@@ -917,7 +974,7 @@ fn try_init_stream_with_backend(
return Some(result.map(|(stream, mode)| {
log::info!("ALSA Direct stream created with mode: {:?}", mode);
state.set_bit_perfect_mode(Some(mode));
StreamType::AlsaDirect(Arc::new(stream))
StreamType::Direct(Arc::new(stream))
}));
}
}
@@ -2246,8 +2303,7 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
let hardware_volume = thread_settings
@@ -2255,7 +2311,7 @@ impl Player {
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
@@ -2695,14 +2751,13 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
@@ -3042,7 +3097,7 @@ impl Player {
carrier,
device
);
*stream_opt = Some(StreamType::AlsaDirect(stream.clone()));
*stream_opt = Some(StreamType::Direct(stream.clone()));
thread_state.set_current_device(Some(device));
*current_track_sample_rate = Some(carrier);
*current_track_channels = Some(2);
@@ -3164,7 +3219,7 @@ impl Player {
if little_endian { "LE" } else { "BE" },
device
);
*stream_opt = Some(StreamType::AlsaDirect(stream.clone()));
*stream_opt = Some(StreamType::Direct(stream.clone()));
thread_state.set_current_device(Some(device));
*current_track_sample_rate = Some(rate);
*current_track_channels = Some(2);
@@ -3394,19 +3449,18 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
)
}
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
)
}
#[cfg(target_os = "linux")]
StreamType::Jack(jack_stream) => {
PlaybackEngine::new_jack(jack_stream.clone())
@@ -3645,14 +3699,13 @@ impl Player {
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
StreamType::Direct(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(
PlaybackEngine::new_direct(
alsa_stream.clone(),
hardware_volume,
thread_viz_tap.clone(),
@@ -5697,6 +5750,31 @@ impl Player {
});
}
let carrier = qbz_dsd::dop_carrier_rate(info.dsd_rate);
// Ask the platform that owns the device. On Windows the
// ALSA probe cannot answer, so this guard was taking its
// `.unwrap_or(true)` and dispatching DoP on a DEFAULT
// rather than on a measurement -- on a DAC that refuses
// 176400, which is the DSD64 carrier.
// WINDOWS: never. Not "probe and decide" -- the DoP
// handler below is `cfg(target_os = "linux")` and answers
// "DoP playback is Linux-only" with a stream error, so
// dispatching here can only ever fail. Converting to PCM
// is the one outcome that plays the track.
//
// It is also FAIL-CLOSED, which this guard has to be. An
// undecoded DoP stream is read as ordinary PCM carrying
// packed DSD and marker bytes: it comes out as very loud
// broadband noise, which threatens tweeters and hearing.
// An earlier draft here fell back to `true` whenever the
// probe could not answer -- preserving exactly the guess
// this work existed to remove.
//
// `wasapi_backend::supported_rates` is what a future
// Windows DoP path would consult, and it already reports
// that the owner's DAC refuses 176400, the DSD64 carrier.
#[cfg(windows)]
let rate_ok = false;
#[cfg(not(windows))]
let rate_ok =
qbz_audio::alsa_backend::get_device_supported_rates(&device)
.map(|r| r.contains(&carrier))
+104 -68
View File
@@ -8,7 +8,9 @@
//! to enable gapless playback. When one source ends, the next is picked up
//! seamlessly without interrupting the PCM stream.
use qbz_audio::{AlsaDirectStream, VisualizerTap};
#[cfg(target_os = "linux")]
use qbz_audio::AlsaDirectStream;
use qbz_audio::VisualizerTap;
#[cfg(target_os = "linux")]
use qbz_audio::JackStream;
use rodio::{mixer::Mixer, Player as RodioPlayer, Source};
@@ -74,9 +76,14 @@ impl<S> SourceQueue<S> {
pub enum PlaybackEngine {
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
Rodio { sink: RodioPlayer },
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
AlsaDirect {
stream: Arc<AlsaDirectStream>,
/// A bit-perfect DIRECT sink with a gapless source queue: ALSA hw: on
/// Linux, WASAPI exclusive on Windows. The two are interchangeable here -
/// the writer thread only ever calls `DirectSink`.
Direct {
stream: Arc<dyn qbz_audio::backend::DirectSink>,
/// Log prefix for THIS sink. Without it `play`/`pause`/`stop`/
/// `set_volume` printed "ALSA Direct Engine" for a WASAPI stream.
label: &'static str,
is_playing: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>,
@@ -127,13 +134,16 @@ impl PlaybackEngine {
Ok(Self::Rodio { sink })
}
/// Create ALSA Direct engine with gapless source queue.
/// Create a bit-perfect direct engine with a gapless source queue.
/// Spawns a single writer thread that lives for the engine's lifetime.
pub fn new_alsa_direct(
stream: Arc<AlsaDirectStream>,
/// Takes ANY direct sink. The log prefix comes from the SINK, so no call
/// site can mislabel it.
pub fn new_direct(
stream: Arc<dyn qbz_audio::backend::DirectSink>,
hardware_volume: bool,
visualizer_tap: Option<VisualizerTap>,
) -> Self {
let label = stream.log_label();
let is_playing = Arc::new(AtomicBool::new(false));
let should_stop = Arc::new(AtomicBool::new(false));
let position_frames = Arc::new(AtomicU64::new(0));
@@ -154,7 +164,7 @@ impl PlaybackEngine {
let visualizer_c = visualizer_tap.clone();
thread::spawn(move || {
alsa_writer_thread(
direct_writer_thread(
stream_c,
playing_c,
stop_c,
@@ -163,13 +173,15 @@ impl PlaybackEngine {
queue_c,
transition_c,
channels,
label,
visualizer_c,
);
})
};
Self::AlsaDirect {
Self::Direct {
stream,
label,
is_playing,
should_stop,
position_frames,
@@ -308,7 +320,8 @@ impl PlaybackEngine {
sink.append(source);
Ok(())
}
Self::AlsaDirect {
Self::Direct {
label,
is_playing,
should_stop,
position_frames,
@@ -328,9 +341,9 @@ impl PlaybackEngine {
should_stop.store(false, Ordering::SeqCst);
source_transition.store(false, Ordering::SeqCst);
is_playing.store(true, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] First source queued, playback starting");
log::info!("[{label}] First source queued, playback starting");
} else {
log::info!("[ALSA Direct Engine] Source queued for gapless transition");
log::info!("[{label}] Source queued for gapless transition");
}
Ok(())
@@ -377,8 +390,10 @@ impl PlaybackEngine {
pub fn play(&self) {
match self {
Self::Rodio { sink } => sink.play(),
Self::AlsaDirect { is_playing, .. } => {
log::info!("[ALSA Direct Engine] Resume requested");
Self::Direct {
label, is_playing, ..
} => {
log::info!("[{label}] Resume requested");
is_playing.store(true, Ordering::SeqCst);
}
#[cfg(target_os = "linux")]
@@ -398,8 +413,10 @@ impl PlaybackEngine {
pub fn pause(&self) {
match self {
Self::Rodio { sink } => sink.pause(),
Self::AlsaDirect { is_playing, .. } => {
log::info!("[ALSA Direct Engine] Pause requested");
Self::Direct {
label, is_playing, ..
} => {
log::info!("[{label}] Pause requested");
is_playing.store(false, Ordering::SeqCst);
}
#[cfg(target_os = "linux")]
@@ -430,8 +447,9 @@ impl PlaybackEngine {
Self::Rodio { sink } => {
sink.stop();
}
Self::AlsaDirect {
Self::Direct {
stream,
label,
is_playing,
should_stop,
playback_thread,
@@ -440,7 +458,7 @@ impl PlaybackEngine {
if should_stop.load(Ordering::SeqCst) {
return; // Already stopped
}
log::info!("[ALSA Direct Engine] Stop requested");
log::info!("[{label}] Stop requested");
should_stop.store(true, Ordering::SeqCst);
is_playing.store(false, Ordering::SeqCst);
@@ -449,7 +467,7 @@ impl PlaybackEngine {
}
if let Err(e) = stream.stop() {
log::warn!("[ALSA Direct Engine] Stop failed: {}", e);
log::warn!("[{label}] Stop failed: {}", e);
}
}
#[cfg(target_os = "linux")]
@@ -498,8 +516,9 @@ impl PlaybackEngine {
pub fn set_volume(&self, volume: f32) {
match self {
Self::Rodio { sink } => sink.set_volume(volume),
Self::AlsaDirect {
Self::Direct {
stream,
label,
hardware_volume,
..
} => {
@@ -507,12 +526,24 @@ impl PlaybackEngine {
#[cfg(target_os = "linux")]
{
if let Err(e) = stream.set_hardware_volume(volume) {
log::warn!("[ALSA Direct Engine] Hardware volume failed: {}", e);
log::warn!("[{label}] Hardware volume failed: {}", e);
}
}
// Additive arm. `set_hardware_volume` is a DirectSink
// method now, so a Windows sink can answer for itself
// instead of the setting silently doing nothing; a sink
// with no hardware volume returns Err and says so. macOS
// and the other targets keep their previous silence --
// CoreAudio has its own path (set_coreaudio_hardware_volume).
#[cfg(windows)]
{
if let Err(e) = stream.set_hardware_volume(volume) {
log::warn!("[{label}] Hardware volume failed: {}", e);
}
}
} else {
log::debug!(
"[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"
"[{label}] Hardware volume control disabled (use DAC/amplifier)"
);
}
}
@@ -535,7 +566,7 @@ impl PlaybackEngine {
pub fn empty(&self) -> bool {
match self {
Self::Rodio { sink } => sink.empty(),
Self::AlsaDirect {
Self::Direct {
is_playing,
source_queue,
..
@@ -560,7 +591,7 @@ impl PlaybackEngine {
pub fn take_source_transition(&self) -> bool {
match self {
Self::Rodio { .. } => false,
Self::AlsaDirect {
Self::Direct {
source_transition, ..
} => source_transition
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
@@ -585,7 +616,7 @@ impl PlaybackEngine {
pub fn position_secs(&self) -> Option<u64> {
match self {
Self::Rodio { .. } => None,
Self::AlsaDirect {
Self::Direct {
position_frames,
stream,
..
@@ -620,7 +651,7 @@ impl PlaybackEngine {
pub fn duration_secs(&self) -> Option<u64> {
match self {
Self::Rodio { .. } => None,
Self::AlsaDirect {
Self::Direct {
duration_frames,
stream,
..
@@ -645,37 +676,41 @@ impl PlaybackEngine {
/// Check if using ALSA Direct engine
#[allow(dead_code)]
pub fn is_alsa_direct(&self) -> bool {
matches!(self, Self::AlsaDirect { .. })
pub fn is_direct(&self) -> bool {
matches!(self, Self::Direct { .. })
}
}
/// Single long-lived writer thread for ALSA Direct.
/// Single long-lived writer thread for any bit-perfect direct sink.
///
/// Continuously reads samples from the current source and writes to ALSA.
/// Continuously reads samples from the current source and writes to the sink.
/// When a source ends, seamlessly picks up the next one from the queue
/// (gapless transition). If no next source is available, drains the ALSA
/// buffer and waits for the next source or a stop signal.
///
/// The source is consumed in ~10 ms pieces instead of the historical fixed
/// 8192-frame blocks. This does not change PCM data or ALSA parameters; it
/// 8192-frame blocks. This does not change PCM data or device parameters; it
/// only keeps the passive visualizer tap from receiving ~186 ms bursts at
/// 44.1 kHz. `snd_pcm_delay` then points snapshots behind the queued PCM at
/// the part currently reaching the DAC.
const ALSA_PCM_FEED_TARGET_MS: u64 = 10;
const ALSA_PCM_FEED_MIN_FRAMES: u64 = 64;
const ALSA_PCM_FEED_MAX_FRAMES: u64 = 8192;
/// 44.1 kHz. Sinks that can measure their queued frames then point snapshots
/// behind the submitted PCM at the part currently reaching the DAC.
///
/// Generic rather than duplicated: ALSA and WASAPI need byte-identical
/// behaviour here — same pacing, gapless hand-off and drain — and two copies
/// of this state machine would drift.
const DIRECT_PCM_FEED_TARGET_MS: u64 = 10;
const DIRECT_PCM_FEED_MIN_FRAMES: u64 = 64;
const DIRECT_PCM_FEED_MAX_FRAMES: u64 = 8192;
fn alsa_pcm_feed_frames(sample_rate: u32) -> usize {
fn direct_pcm_feed_frames(sample_rate: u32) -> usize {
let frames = u64::from(sample_rate)
.saturating_mul(ALSA_PCM_FEED_TARGET_MS)
.saturating_mul(DIRECT_PCM_FEED_TARGET_MS)
.div_ceil(1000)
.clamp(ALSA_PCM_FEED_MIN_FRAMES, ALSA_PCM_FEED_MAX_FRAMES);
.clamp(DIRECT_PCM_FEED_MIN_FRAMES, DIRECT_PCM_FEED_MAX_FRAMES);
frames as usize
}
fn alsa_writer_thread(
stream: Arc<AlsaDirectStream>,
fn direct_writer_thread<S: qbz_audio::backend::DirectSink + ?Sized>(
stream: Arc<S>,
is_playing: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>,
position_frames: Arc<AtomicU64>,
@@ -683,9 +718,10 @@ fn alsa_writer_thread(
source_queue: Arc<SourceQueue<BoxedSampleIter>>,
source_transition: Arc<AtomicBool>,
channels: u16,
label: &'static str,
visualizer_tap: Option<VisualizerTap>,
) {
let chunk_frames = alsa_pcm_feed_frames(stream.sample_rate());
let chunk_frames = direct_pcm_feed_frames(stream.sample_rate());
let chunk_samples = chunk_frames * channels as usize;
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
let mut current_source: Option<BoxedSampleIter> = None;
@@ -698,15 +734,15 @@ fn alsa_writer_thread(
tap.clear_output_delay();
}
log::info!(
"[ALSA Direct Engine] Writer thread started (gapless-capable, viz feed {} frames / ~{} ms)",
"[{label}] Writer thread started (gapless-capable, viz feed {} frames / ~{} ms)",
chunk_frames,
ALSA_PCM_FEED_TARGET_MS
DIRECT_PCM_FEED_TARGET_MS
);
'thread: loop {
// Check global stop
if should_stop.load(Ordering::SeqCst) {
log::info!("[ALSA Direct Engine] Stop signal, writer thread exiting");
log::info!("[{label}] Stop signal, writer thread exiting");
break 'thread;
}
@@ -718,7 +754,7 @@ fn alsa_writer_thread(
current_source = Some(src);
total_frames = 0;
position_frames.store(0, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] Acquired new source from queue");
log::info!("[{label}] Acquired new source from queue");
}
None => {
// No source available, loop back to check stop
@@ -736,7 +772,7 @@ fn alsa_writer_thread(
}
// Keep the passive tap at the previous audible position while this
// next piece is pulled rapidly from the source. Once ALSA accepts it,
// next piece is pulled rapidly from the source. Once the sink accepts it,
// the delay query below advances the tap by exactly what the device
// consumed. Shared/callback backends leave this offset at zero.
let visualizer_enabled = visualizer_tap
@@ -750,7 +786,7 @@ fn alsa_writer_thread(
Err(error) => {
if !delay_error_reported {
log::warn!(
"[ALSA Direct Engine] Visualizer delay query failed; using paced feed without playhead compensation: {}",
"[{label}] Visualizer delay query failed; using paced feed without playhead compensation: {}",
error
);
delay_error_reported = true;
@@ -797,10 +833,10 @@ fn alsa_writer_thread(
}
}
// Write whatever we have to ALSA (even partial chunks on source end)
// Write whatever we have to the direct sink (even partial chunks on source end)
if !buffer_f32.is_empty() {
if let Err(e) = stream.write_f32(&buffer_f32) {
log::error!("[ALSA Direct Engine] Write failed: {}", e);
log::error!("[{label}] Write failed: {}", e);
break 'thread;
}
@@ -819,7 +855,7 @@ fn alsa_writer_thread(
Err(error) => {
if !delay_error_reported {
log::warn!(
"[ALSA Direct Engine] Visualizer delay query failed; using paced feed without playhead compensation: {}",
"[{label}] Visualizer delay query failed; using paced feed without playhead compensation: {}",
error
);
delay_error_reported = true;
@@ -835,14 +871,14 @@ fn alsa_writer_thread(
if source_ended {
log::info!(
"[ALSA Direct Engine] Source ended (total frames: {})",
"[{label}] Source ended (total frames: {})",
total_frames
);
// Try to get next source immediately (gapless transition)
match source_queue.try_pop() {
Some(next_src) => {
log::info!("[ALSA Direct Engine] Gapless transition to next source");
log::info!("[{label}] Gapless transition to next source");
current_source = Some(next_src);
total_frames = 0;
position_frames.store(0, Ordering::SeqCst);
@@ -852,9 +888,9 @@ fn alsa_writer_thread(
}
None => {
// No next source — this is a natural end of playback
log::info!("[ALSA Direct Engine] No next source, draining ALSA buffer");
log::info!("[{label}] No next source, draining the device buffer");
if let Err(e) = stream.drain() {
log::warn!("[ALSA Direct Engine] Drain failed: {}", e);
log::warn!("[{label}] Drain failed: {}", e);
}
current_source = None;
is_playing.store(false, Ordering::SeqCst);
@@ -868,12 +904,12 @@ fn alsa_writer_thread(
if let Some(tap) = visualizer_tap.as_ref() {
tap.clear_output_delay();
}
log::info!("[ALSA Direct Engine] Writer thread finished");
log::info!("[{label}] Writer thread finished");
}
/// Single long-lived feeder thread for JACK (#263 Tier 3).
///
/// Mirrors `alsa_writer_thread`, but writes graph-rate interleaved STEREO f32
/// Mirrors `direct_writer_thread`, but writes graph-rate interleaved STEREO f32
/// into the JACK client's lock-free ring buffer via `JackStream::write_f32`
/// (the RT process callback drains it), pacing itself when the ring is full.
/// Sources are resampled to the graph rate + stereo at `append` time.
@@ -976,7 +1012,7 @@ impl Drop for PlaybackEngine {
/// Single long-lived writer thread for DoP (DSD over PCM).
///
/// Mirrors `alsa_writer_thread`'s shape: pulls pre-packed S32 DoP words from
/// Mirrors `direct_writer_thread`'s shape: pulls pre-packed S32 DoP words from
/// the current source, writes them VERBATIM, and picks up the next queued
/// source seamlessly (gapless DSD). Differences forced by the format:
/// - pause writes 0x69 DSD silence (with valid alternating markers) instead
@@ -1090,20 +1126,20 @@ fn dop_writer_thread(
#[cfg(test)]
mod tests {
use super::alsa_pcm_feed_frames;
use super::direct_pcm_feed_frames;
#[test]
fn alsa_pcm_feed_stays_near_ten_ms_across_pcm_rates() {
assert_eq!(alsa_pcm_feed_frames(44_100), 441);
assert_eq!(alsa_pcm_feed_frames(48_000), 480);
assert_eq!(alsa_pcm_feed_frames(96_000), 960);
assert_eq!(alsa_pcm_feed_frames(192_000), 1_920);
assert_eq!(alsa_pcm_feed_frames(768_000), 7_680);
fn direct_pcm_feed_stays_near_ten_ms_across_pcm_rates() {
assert_eq!(direct_pcm_feed_frames(44_100), 441);
assert_eq!(direct_pcm_feed_frames(48_000), 480);
assert_eq!(direct_pcm_feed_frames(96_000), 960);
assert_eq!(direct_pcm_feed_frames(192_000), 1_920);
assert_eq!(direct_pcm_feed_frames(768_000), 7_680);
}
#[test]
fn alsa_pcm_feed_has_defensive_bounds() {
assert_eq!(alsa_pcm_feed_frames(0), 64);
assert_eq!(alsa_pcm_feed_frames(u32::MAX), 8_192);
fn direct_pcm_feed_has_defensive_bounds() {
assert_eq!(direct_pcm_feed_frames(0), 64);
assert_eq!(direct_pcm_feed_frames(u32::MAX), 8_192);
}
}
+34 -2
View File
@@ -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"
@@ -133,8 +137,9 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
# is the Wayland-correct one and needs no extra system libs.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). macOS uses the
# native implementation in src/tray_macos.rs; Windows currently has no tray.
# Linux system tray via StatusNotifierItem (src/tray_linux.rs). macOS and
# Windows use their native implementations in src/tray_macos.rs and
# src/tray_windows.rs.
#
# default-features = false: ksni's default is "tokio", which flips the shared
# zbus 5 executor to tokio graph-wide. We only use the blocking API on
@@ -157,6 +162,12 @@ cxx-qt-build = "0.7"
qt-build-utils = "0.7"
cc = "1"
[target.'cfg(windows)'.build-dependencies]
# Icon + VERSIONINFO + the longPathAware manifest, embedded as PE resources.
# winresource, not winres: winres has been unmaintained since 2021 and this
# is its maintained fork.
winresource = "0.1"
# macOS only: the Dock-icon activation policy behind the "Hide Dock icon when
# closed to menu bar" setting (Spotify behaviour — close puts the app in the
# menu bar and drops the Dock icon; minimize still goes to the Dock).
@@ -168,6 +179,27 @@ cc = "1"
# ⚠ NOT COMPILED OR TESTED HERE. This tree builds on Linux; the macOS arm is
# behind cfg, so `cargo build` cannot see it. First real check is a macOS
# build — the Mac mini (192.168.0.69) is where that happens.
[target.'cfg(target_os = "windows")'.dependencies]
# SetCurrentProcessExplicitAppUserModelID: the identity Windows groups the
# taskbar button under and matches a toast's app_id against. 0.61 is already
# 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",
] }
[target.'cfg(target_os = "macos")'.dependencies]
# DIRECT objc2, added 2026-08-05 for the menu-bar tray: `declare_class!`,
# `msg_send_id!`, `sel!`, `ClassType`, `DeclaredClass`, `mutability` and
+94 -2
View File
@@ -5,6 +5,12 @@ use cxx_qt_build::{CxxQtBuilder, QmlModule};
/// Collect every file under `dir` (recursive), crate-root-relative — the
/// baked icon variants (qml/assets/icons/<tint>/<name>.svg) are too many to
/// list by hand.
///
/// The strings become qrc ALIASES verbatim (qt-build-utils writes
/// `<file alias="{path}">` from `Path::display()`), and QML asks with `/`
/// (`QbzIcon.qml:231`, `FontPreload.qml:40-46`). On Windows `read_dir` joins
/// with `\`, so normalise here or every asset fails with
/// "QQuickImage: Cannot open".
fn collect_qrc_files(dir: &Path, out: &mut Vec<String>) {
for entry in
std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
@@ -13,11 +19,46 @@ fn collect_qrc_files(dir: &Path, out: &mut Vec<String>) {
if path.is_dir() {
collect_qrc_files(&path, out);
} else {
out.push(path.to_string_lossy().into_owned());
out.push(qrc_alias(&path));
}
}
}
/// A qrc alias is always `/`-separated, whatever the host separator.
///
/// Rewritten only when the BUILD HOST is Windows. On Unix a backslash is a
/// LEGAL filename character, so rewriting it there would quietly point the
/// alias at a different file (or at none). `cfg!` in a build script reads the
/// host, which is exactly the separator `read_dir` just produced.
fn qrc_alias(path: &Path) -> String {
let raw = path.to_string_lossy();
if cfg!(windows) {
raw.replace('\\', "/")
} else {
raw.into_owned()
}
}
/// MSVC reports `__cplusplus` as `199711L` no matter what `/std:` says, unless
/// `/Zc:__cplusplus` is passed. Qt refuses that value outright —
/// `qcompilerdetection.h:1317` raises "Qt requires a C++17 compiler, and a
/// suitable value for __cplusplus" on EVERY translation unit that includes a
/// Qt header, ours and moc's alike, so the first Windows build cannot get past
/// the Qt headers. And without `/permissive-` the Qt 6.9 headers do not parse
/// under MSVC's default non-conforming mode: measured here, `qtmochelpers.h:262`
/// C2065 'result', `qcomparehelpers.h:1348` C2968 recursive alias, and C2737 on
/// every moc `staticMetaObject`. Both flags are what Qt's own CMake passes;
/// neither cc-rs nor cxx-qt-build adds either.
///
/// The build script runs on the HOST, so ask cargo for the TARGET env rather
/// than `cfg!()` — identical for a native build, not for a cross one.
fn apply_msvc_qt_flags(cc: &mut cc::Build) {
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
cc.flag("/Zc:__cplusplus");
cc.flag("/permissive-");
}
}
/// Every RHI variant a Qt Quick shader should carry.
///
/// `qsb --qt6` is only `100 es,120,150` + HLSL 50 + MSL 12. This is that plus
@@ -82,9 +123,23 @@ fn find_qsb() -> Option<std::path::PathBuf> {
return Some(p);
}
}
// Windows: `qsb.exe`, and `Path::is_file()` does NOT append `.exe` (Rust's
// std only does that for a full path handed to `Command`), so probe both.
let names: &[&str] = if cfg!(windows) { &["qsb.exe", "qsb"] } else { &["qsb"] };
if let Ok(path) = std::env::var("PATH") {
for dir in std::env::split_paths(&path) {
let p = dir.join("qsb");
for name in names {
let p = dir.join(name);
if p.is_file() {
return Some(p);
}
}
}
}
// aqt / install-qt-action export QT_ROOT_DIR (e.g. F:\Qt\6.9.3\msvc2022_64).
if let Ok(root) = std::env::var("QT_ROOT_DIR") {
for name in names {
let p = std::path::Path::new(&root).join("bin").join(name);
if p.is_file() {
return Some(p);
}
@@ -296,6 +351,7 @@ fn build_rhi_items() {
let mut cc = cc::Build::new();
cc.cpp(true).std("c++17").pic(true).include("cxx"); // the moc outputs do `#include "<name>.h"`
apply_msvc_qt_flags(&mut cc);
for item in [
"linebed_item",
"plasma_item",
@@ -326,6 +382,13 @@ fn build_rhi_items() {
// when hybrid-GPU implicit layers are active.
println!("cargo:rerun-if-changed=cxx/qt_vulkan_probe.cpp");
cc.file("cxx/qt_vulkan_probe.cpp");
println!("cargo:rerun-if-changed=cxx/win_shell.cpp");
cc.file("cxx/win_shell.cpp");
// Shell_NotifyIconW tray. No Q_OBJECT, so no moc; the body is inside
// `#ifdef _WIN32` and compiles to nothing on Linux and macOS.
println!("cargo:rerun-if-changed=cxx/win_tray.cpp");
println!("cargo:rerun-if-changed=cxx/win_tray.h");
cc.file("cxx/win_tray.cpp");
for p in qtbuild.include_paths() {
cc.include(p);
}
@@ -607,6 +670,7 @@ fn main() {
// QbzAbout's two documents.
"qml/shell/AboutModal.qml",
"qml/shell/WhatsNewModal.qml",
"qml/shell/WindowsDisclaimerModal.qml",
// Immersive mode (2026-08-02 immersive-port contract §2) —
// its own module directory like views/local/ and
// views/playlistmanager/. B2 shipped the root overlay + the
@@ -860,5 +924,33 @@ fn main() {
qrc_files: &qrc_refs,
..Default::default()
})
.cc_builder(apply_msvc_qt_flags)
.build();
embed_windows_resources();
}
/// Icon, VERSIONINFO and the manifest, as PE resources.
///
/// The manifest declares `longPathAware` and NOTHING ELSE. Never add
/// `dpiAware`/`dpiAwareness`: Qt 6 sets PerMonitorV2 itself at startup, a
/// manifest declaration is immutable, and the two together either log a
/// permanent COM error 0x5 or silently degrade scaling.
///
/// `build.rs` runs with `crates/qbz-qt` as its working directory, hence
/// `../../packaging`.
#[cfg(windows)]
fn embed_windows_resources() {
println!("cargo:rerun-if-changed=../../packaging/icons/icon.ico");
println!("cargo:rerun-if-changed=../../packaging/windows/qbz.exe.manifest");
let mut res = winresource::WindowsResource::new();
res.set_icon("../../packaging/icons/icon.ico");
res.set_manifest_file("../../packaging/windows/qbz.exe.manifest");
res.set("ProductName", "QBZ");
res.set("FileDescription", "QBZ — Qobuz hi-res player");
res.set("LegalCopyright", "MIT — github.com/vicrodh/qbz");
res.compile().expect("windows resource");
}
#[cfg(not(windows))]
fn embed_windows_resources() {}
+132
View File
@@ -0,0 +1,132 @@
// Windows shell helpers with no Q_OBJECT of their own.
//
// Compiled on EVERY platform on purpose: the bodies are portable Qt and only
// the caller's use of the WId is Windows-specific. Keeping it unconditional
// means the file is type-checked by the Linux and macOS builds too, which is
// where a signature drift would otherwise go unnoticed until a Windows CI run.
#include <QtCore/QObject>
#include <QtGui/QGuiApplication>
#include <QtGui/QSessionManager>
#include <QtGui/QWindow>
#ifdef _WIN32
#include <QtCore/QAbstractNativeEventFilter>
#include <QtCore/QByteArray>
#include <QtCore/QCoreApplication>
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif
// The top-level window's native handle, or nullptr before one is shown.
//
// MUST NOT be called before exec(): QWindow::winId() CREATES the platform
// window as a side effect, so an early call would hand back a handle for a
// window Qt has not finished setting up, and the real one would then get a
// different HWND. Callers hop to the GUI thread first; winId() is not
// thread-safe.
extern "C" void *qbz_main_window_hwnd()
{
const auto windows = QGuiApplication::topLevelWindows();
for (QWindow *w : windows)
if (w->isVisible() && w->type() == Qt::Window)
return reinterpret_cast<void *>(w->winId());
return nullptr;
}
#ifdef _WIN32
namespace {
// Give the custom-chrome window a real hit-test back.
//
// MEASURED, not assumed. With `Qt::CustomizeWindowHint` -- which is the only
// way to stop Qt painting its own minimise/maximise/close cluster on top of
// QBZ's header -- Qt answers WM_NCHITTEST with HTNOWHERE for the ENTIRE
// window: centre, header, player bar and sidebar all returned 0. Windows
// delivers no mouse input to HTNOWHERE, so the app looked frozen while it was
// running perfectly and the tray menu still drove playback.
//
// Without that hint Qt hit-tests correctly but populates the default title
// hints, and its customized-title path draws the buttons over our header. No
// combination of public flags gives all three of: no drawn buttons, working
// clicks, and the WS_THICKFRAME that Windows tiling needs.
//
// So the flags keep CustomizeWindowHint and this filter answers the hit test
// the way an ordinary sizable, caption-less window would: DefWindowProcW
// returns the eight edge codes near the frame and HTCLIENT everywhere else.
// Nothing here invents geometry -- it defers to the same code every other
// borderless-resizable window uses.
//
// Dragging is unaffected and stays with QML's `startSystemMove()`: with no
// caption there is no HTCAPTION band to inherit, which is the arrangement the
// header was already written for.
class QbzNcHitTestFilter : public QAbstractNativeEventFilter
{
public:
bool nativeEventFilter(const QByteArray &type, void *message, qintptr *result) override
{
if (type != QByteArrayLiteral("windows_generic_MSG"))
return false;
MSG *msg = static_cast<MSG *>(message);
if (!msg || msg->message != WM_NCHITTEST)
return false;
// NARROW ON PURPOSE: only a window that is sizable AND caption-less,
// which is exactly the custom-chrome main window. Menus, tooltips and
// popups have no WS_THICKFRAME; a window showing the system title bar
// has WS_CAPTION and must keep Qt's own answer. Getting this wrong
// would break hit-testing everywhere instead of fixing it in one
// place.
const LONG_PTR style = GetWindowLongPtrW(msg->hwnd, GWL_STYLE);
if ((style & WS_THICKFRAME) == 0 || (style & WS_CAPTION) == WS_CAPTION)
return false;
*result = static_cast<qintptr>(
DefWindowProcW(msg->hwnd, WM_NCHITTEST, msg->wParam, msg->lParam));
return true;
}
};
QbzNcHitTestFilter *g_hit_filter = nullptr;
} // namespace
#endif // _WIN32
// Install the hit-test filter. Idempotent; a no-op off Windows.
extern "C" void qbz_install_hittest_filter()
{
#ifdef _WIN32
if (g_hit_filter)
return;
g_hit_filter = new QbzNcHitTestFilter;
QCoreApplication::instance()->installNativeEventFilter(g_hit_filter);
#endif
}
// WM_QUERYENDSESSION -> QGuiApplication::commitDataRequest, via Qt's Windows
// session manager. It fires BEFORE Windows decides whether to proceed with the
// logoff and Qt blocks until the handler returns, which makes it the one safe
// place to persist synchronously: anything deferred to a queued connection or
// another thread races the process being killed.
extern "C" void qbz_install_commit_data_handler(void (*cb)())
{
#ifndef QT_NO_SESSIONMANAGER
// Once. Qt::UniqueConnection does NOT deduplicate lambdas -- each connect
// makes an independent connection -- so a second install would run the
// session persist twice on every logoff.
static bool installed = false;
if (installed)
return;
installed = true;
QObject::connect(qApp, &QGuiApplication::commitDataRequest, qApp,
[cb](QSessionManager &) { cb(); });
#else
// A Qt built without the session manager has no commitDataRequest at all,
// and this file compiles on every platform.
(void)cb;
#endif
}
+356
View File
@@ -0,0 +1,356 @@
// Windows notification-area icon, modelled on Qt's own
// qwindowssystemtrayicon.cpp. The whole body is inside `#ifdef _WIN32` so the
// file can sit unconditionally in build.rs beside font_fallback.cpp and
// compile to nothing on Linux and macOS.
#include "win_tray.h"
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
// ORDER MATTERS, and it is not alphabetical. <windows.h> must come first:
// <shellapi.h> uses DECLSPEC_IMPORT, HDROP and EXTERN_C without declaring
// them, so including it first produces a wall of C4430/C2146 inside the SDK
// header itself and points nowhere near this file.
#include <windows.h>
#include <shellapi.h>
// GET_X_LPARAM / GET_Y_LPARAM: signed extraction, which matters on a
// multi-monitor desktop where a secondary display has negative coordinates.
#include <windowsx.h>
#pragma comment(lib, "shell32.lib")
#pragma comment(lib, "user32.lib")
namespace {
// WM_APP+101: the tray's callback message. Anything in the WM_APP range is
// ours to define, and this window class receives nothing else.
constexpr UINT kTrayCallback = WM_APP + 101;
constexpr UINT kIconId = 1;
enum MenuId : UINT {
kMenuPlayPause = 1001,
kMenuNext,
kMenuPrevious,
kMenuShowHide,
kMenuQuit,
};
HWND g_hwnd = nullptr;
HICON g_icon = nullptr;
// LoadImageW hands us an icon WE own; the LoadIconW(nullptr, ...) fallback is a
// shared system icon that must never be destroyed. Which one we got decides
// whether qbz_win_tray_destroy calls DestroyIcon.
bool g_icon_owned = false;
UINT g_taskbar_created = 0; // RegisterWindowMessageW("TaskbarCreated")
bool g_playing = false;
wchar_t g_tooltip[128] = L"QBZ";
wchar_t g_label_play_pause[64] = L"Play/Pause";
wchar_t g_label_next[64] = L"Next Track";
wchar_t g_label_previous[64] = L"Previous Track";
wchar_t g_label_show_hide[64] = L"Show/Hide Window";
wchar_t g_label_quit[64] = L"Quit QBZ";
void (*g_on_left_click)() = nullptr;
void (*g_on_play_pause)() = nullptr;
void (*g_on_next)() = nullptr;
void (*g_on_previous)() = nullptr;
void (*g_on_quit)() = nullptr;
void copy_label(wchar_t *dst, size_t cap, const wchar_t *src)
{
if (!src)
return;
wcsncpy_s(dst, cap, src, _TRUNCATE);
}
// The icon Windows draws. winresource assigns id 1 to the first icon it
// embeds (W4), and LoadImageW at 16x16 picks the matching RT_ICON out of that
// group. If it is ever missing we fall back to the generic application icon
// rather than returning null: a tray entry with the wrong picture is a cosmetic
// bug, a tray entry that never appears is close-to-tray hiding the window with
// no way back.
//
// Sets g_icon_owned: without LR_SHARED the LoadImageW handle is OURS and leaks
// unless destroyed, while the LoadIconW fallback is shared and must not be.
HICON load_tray_icon()
{
HINSTANCE inst = GetModuleHandleW(nullptr);
const int cx = GetSystemMetrics(SM_CXSMICON);
const int cy = GetSystemMetrics(SM_CYSMICON);
if (HICON icon = static_cast<HICON>(
LoadImageW(inst, MAKEINTRESOURCEW(1), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR))) {
g_icon_owned = true;
return icon;
}
g_icon_owned = false;
// MAKEINTRESOURCEW, not IDI_APPLICATION: this build does not define
// UNICODE, so the bare macro expands to the ANSI MAKEINTRESOURCEA and
// will not convert to the LPCWSTR LoadIconW wants. 32512 IS
// IDI_APPLICATION. Every Win32 call in this file is an explicit W for the
// same reason.
return LoadIconW(nullptr, MAKEINTRESOURCEW(32512));
}
NOTIFYICONDATAW base_data()
{
NOTIFYICONDATAW nid = {};
nid.cbSize = sizeof(nid);
nid.hWnd = g_hwnd;
nid.uID = kIconId;
return nid;
}
bool add_icon()
{
NOTIFYICONDATAW nid = base_data();
// NIF_SHOWTIP is REQUIRED with version 4. Version 4 suppresses the standard
// tooltip unless it is asked for, so without this flag the icon appears,
// the tooltip is set on every track, and nothing ever shows on hover.
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP;
nid.uCallbackMessage = kTrayCallback;
nid.hIcon = g_icon;
wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE);
if (!Shell_NotifyIconW(NIM_ADD, &nid))
return false;
// Version 4 is what makes the callback carry the event in LOWORD(lParam)
// and real screen coordinates in wParam. Without it the right-click
// arrives as a bare WM_RBUTTONUP with no position and the menu opens in
// the wrong place on a multi-monitor desktop.
//
// The result is CHECKED. wnd_proc decodes callbacks as version 4 and
// nothing else, so a silent negotiation failure would leave a live icon
// whose clicks and menu do nothing -- worse than no icon at all, because
// close-to-tray would then hide the window behind it.
NOTIFYICONDATAW ver = base_data();
ver.uVersion = NOTIFYICON_VERSION_4;
if (!Shell_NotifyIconW(NIM_SETVERSION, &ver)) {
NOTIFYICONDATAW del = base_data();
Shell_NotifyIconW(NIM_DELETE, &del);
return false;
}
return true;
}
void show_menu(int x, int y)
{
HMENU menu = CreatePopupMenu();
if (!menu)
return;
AppendMenuW(menu, MF_STRING, kMenuPlayPause, g_label_play_pause);
AppendMenuW(menu, MF_STRING, kMenuPrevious, g_label_previous);
AppendMenuW(menu, MF_STRING, kMenuNext, g_label_next);
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, kMenuShowHide, g_label_show_hide);
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, kMenuQuit, g_label_quit);
// The documented ritual. Without the SetForegroundWindow the menu does not
// dismiss when the user clicks elsewhere; without the trailing PostMessage
// it can stay up after a selection (MSDN TrackPopupMenu remarks).
SetForegroundWindow(g_hwnd);
const UINT cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY,
x, y, 0, g_hwnd, nullptr);
PostMessageW(g_hwnd, WM_NULL, 0, 0);
DestroyMenu(menu);
// Hand keyboard focus back to the notification area, which the Shell
// documentation asks for after a context menu closes -- particularly when
// it was dismissed with Escape rather than a selection.
NOTIFYICONDATAW focus = base_data();
Shell_NotifyIconW(NIM_SETFOCUS, &focus);
switch (cmd) {
case kMenuPlayPause:
if (g_on_play_pause)
g_on_play_pause();
break;
case kMenuNext:
if (g_on_next)
g_on_next();
break;
case kMenuPrevious:
if (g_on_previous)
g_on_previous();
break;
case kMenuShowHide:
if (g_on_left_click)
g_on_left_click();
break;
case kMenuQuit:
if (g_on_quit)
g_on_quit();
break;
default:
break;
}
}
LRESULT CALLBACK tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
// Explorer restarted (or crashed): every icon is gone from the new taskbar
// and each app has to add its own back. Missing this is why a tray icon
// "disappears forever" after an Explorer restart.
if (msg == g_taskbar_created && g_taskbar_created != 0) {
add_icon();
return 0;
}
if (msg == kTrayCallback) {
// Version 4 packing: event in the low word of lParam, cursor position
// in wParam.
switch (LOWORD(lparam)) {
case NIN_SELECT:
case NIN_KEYSELECT:
if (g_on_left_click)
g_on_left_click();
return 0;
case WM_CONTEXTMENU:
show_menu(GET_X_LPARAM(wparam), GET_Y_LPARAM(wparam));
return 0;
default:
return 0;
}
}
return DefWindowProcW(hwnd, msg, wparam, lparam);
}
bool ensure_window()
{
if (g_hwnd)
return true;
HINSTANCE inst = GetModuleHandleW(nullptr);
static const wchar_t *kClass = L"QbzTrayMessageWindow";
WNDCLASSEXW wc = {};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = tray_wnd_proc;
wc.hInstance = inst;
wc.lpszClassName = kClass;
// Re-registering the same class returns 0 with ERROR_CLASS_ALREADY_EXISTS,
// which is fine on a second create.
RegisterClassExW(&wc);
// NOT HWND_MESSAGE. A message-only window never receives the broadcast
// TaskbarCreated, so the icon would never come back after an Explorer
// restart. WS_EX_TOOLWINDOW keeps this invisible helper out of the taskbar
// and the Alt+Tab list, which is the reason HWND_MESSAGE is tempting.
g_hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, kClass, L"QbzTrayMessageWindow",
WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, inst, nullptr);
if (!g_hwnd)
return false;
g_taskbar_created = RegisterWindowMessageW(L"TaskbarCreated");
// A broadcast message is filtered out by UIPI unless the window opts in.
if (g_taskbar_created)
ChangeWindowMessageFilterEx(g_hwnd, g_taskbar_created, MSGFLT_ALLOW, nullptr);
return true;
}
} // namespace
extern "C" void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next,
const wchar_t *previous, const wchar_t *show_hide,
const wchar_t *quit)
{
copy_label(g_label_play_pause, 64, play_pause);
copy_label(g_label_next, 64, next);
copy_label(g_label_previous, 64, previous);
copy_label(g_label_show_hide, 64, show_hide);
copy_label(g_label_quit, 64, quit);
}
extern "C" void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(),
void (*on_next)(), void (*on_previous)(),
void (*on_quit)())
{
g_on_left_click = on_left_click;
g_on_play_pause = on_play_pause;
g_on_next = on_next;
g_on_previous = on_previous;
g_on_quit = on_quit;
}
extern "C" bool qbz_win_tray_create(const wchar_t *tooltip)
{
if (tooltip)
wcsncpy_s(g_tooltip, tooltip, _TRUNCATE);
// Idempotent: a second create must not stack a second NIM_ADD on a live
// icon (which fails and would report the whole tray dead while the first
// icon is still sitting there). Remove the old one first.
if (g_hwnd) {
NOTIFYICONDATAW del = base_data();
Shell_NotifyIconW(NIM_DELETE, &del);
}
if (!ensure_window())
return false;
if (!g_icon)
g_icon = load_tray_icon();
if (add_icon())
return true;
// Failed. Give back the window and the icon rather than leaving them
// parked for the life of the process: the Rust side will not retry, so
// nothing else would ever free them.
if (g_icon && g_icon_owned)
DestroyIcon(g_icon);
g_icon = nullptr;
g_icon_owned = false;
DestroyWindow(g_hwnd);
g_hwnd = nullptr;
return false;
}
extern "C" void qbz_win_tray_set_tooltip(const wchar_t *tooltip)
{
if (!tooltip)
return;
// szTip is 128 wchar INCLUDING the terminator; _TRUNCATE keeps the copy
// safe and Windows simply elides the rest in the hover text.
wcsncpy_s(g_tooltip, tooltip, _TRUNCATE);
if (!g_hwnd)
return;
NOTIFYICONDATAW nid = base_data();
// NIF_SHOWTIP again: it is a per-call flag, not sticky state.
nid.uFlags = NIF_TIP | NIF_SHOWTIP;
wcsncpy_s(nid.szTip, g_tooltip, _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
extern "C" void qbz_win_tray_set_playing(bool playing)
{
// Recorded for the menu; the label itself is the platform-neutral
// "Play/Pause" the other two trays use, so nothing needs rebuilding here.
g_playing = playing;
}
extern "C" void qbz_win_tray_destroy(void)
{
if (!g_hwnd)
return;
NOTIFYICONDATAW nid = base_data();
Shell_NotifyIconW(NIM_DELETE, &nid);
DestroyWindow(g_hwnd);
g_hwnd = nullptr;
// LoadImageW WITHOUT LR_SHARED returns an icon this process owns, and it
// leaks unless destroyed. Only the LoadIconW(nullptr, ...) fallback is a
// shared system icon, which must never be destroyed.
if (g_icon && g_icon_owned)
DestroyIcon(g_icon);
g_icon = nullptr;
g_icon_owned = false;
}
#endif // _WIN32
+40
View File
@@ -0,0 +1,40 @@
// Windows notification-area icon. C ABI so the Rust seam (src/tray_windows.rs)
// can drive it exactly as tray_linux.rs drives the StatusNotifierItem and
// tray_macos.rs drives the NSStatusItem.
//
// EVERY function here is GUI-THREAD ONLY. The icon owns a real (hidden) window
// and a message loop; touching it from tokio would post to a thread that never
// pumps.
#pragma once
#include <stdbool.h>
#include <wchar.h>
#ifdef __cplusplus
extern "C" {
#endif
// The five menu labels, already translated by the Rust side (the same
// qbz_i18n keys Linux and macOS use, so the three menus stay identical).
// Must be called before qbz_win_tray_create; the strings are copied.
void qbz_win_tray_set_labels(const wchar_t *play_pause, const wchar_t *next,
const wchar_t *previous, const wchar_t *show_hide,
const wchar_t *quit);
// Installed once, before create. Each maps 1:1 to a tray_qt dispatcher.
void qbz_win_tray_set_callbacks(void (*on_left_click)(), void (*on_play_pause)(),
void (*on_next)(), void (*on_previous)(),
void (*on_quit)());
// NIM_ADD + NIM_SETVERSION(4). False if the message window or the icon could
// not be created -- the caller must then leave the tray handle empty, or
// close-to-tray would hide the window with nothing to restore it from.
bool qbz_win_tray_create(const wchar_t *tooltip);
void qbz_win_tray_set_tooltip(const wchar_t *tooltip);
void qbz_win_tray_set_playing(bool playing);
void qbz_win_tray_destroy(void);
#ifdef __cplusplus
}
#endif
+60 -2
View File
@@ -140,11 +140,66 @@ ApplicationWindow {
// the frame, and the reference never draws its own there
// ("Linux only — macOS keeps the native traffic lights",
// WindowControls.slint:1-2).
// WINDOWS takes the macOS shape, not the Linux one, and the difference is
// the whole feature (W27).
//
// `Qt.FramelessWindowHint` removes WS_THICKFRAME, and THAT is what the
// shell keys tiling on: Win+Arrow and Aero Snap by dragging to an edge both
// stop, along with the DWM drop shadow and the Win11 rounded corners. So
// "hide title bar" was not making the app refuse the shortcuts -- it had
// stopped being a window the shell could tile.
//
// NOT restored, and it cannot be from flags alone: the Windows 11 Snap
// Layouts flyout that appears when hovering a maximise button. Windows asks
// for it by hit-testing that pixel as HTMAXBUTTON, and QBZ's maximise
// button is a QML item inside the client area, so it answers HTCLIENT.
// Wiring it would mean reporting HTMAXBUTTON over that one rect from the
// native filter -- deliberate future work, not something this arm does.
//
// Qt 6.9's Windows plugin already implements WM_NCCALCSIZE -> 0,
// DwmExtendFrameIntoClientArea, the eight resize edges and
// safeAreaMargins() for `ExpandedClientAreaHint` -- the same flags the
// macOS arm uses. `CustomizeWindowHint` with no button hints then
// suppresses Qt's own painted title cluster
// (qwindowswindow.cpp :3561/:3600/:3624/:3645) while the window KEEPS
// WS_CAPTION, so the shell still treats it as tileable.
//
// It also provides no HTCAPTION drag band of its own (:3332-3338: with
// both isDefaultTitleBar and isCustomized false the whole title block is
// skipped), which is why HeaderBar's QML `startSystemMove()` stays: it
// issues SC_MOVE, and SC_MOVE drags DO snap. The eight QML resize grips
// stay enabled too and do not fight the native edges -- Qt answers
// WM_NCHITTEST first, so a press on the border never reaches QML.
flags: QbzShell.systemTitleBar
? Qt.Window
: (QbzShell.isMacos
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
: (Qt.Window | Qt.FramelessWindowHint))
: (QbzShell.isWindows
// Both halves were MEASURED on the box, and neither is
// optional:
//
// WITHOUT CustomizeWindowHint, Qt populates the default title
// hints and its customized-title path DRAWS minimise/maximise/
// close over the QBZ header.
//
// WITH it, Qt answers WM_NCHITTEST with HTNOWHERE for every
// pixel -- centre, header, player bar and sidebar all returned
// 0 -- and Windows sends no mouse input to HTNOWHERE, so the
// whole app becomes unclickable while still running.
//
// No public flag combination gives no-buttons + clicks +
// the WS_THICKFRAME that tiling needs, so the hint stays and
// `win_shell::install_hittest_filter()` answers the hit test
// instead. What this arm must NOT do is drop the hint: that
// trades the buttons back in.
//
// Frameless is not the answer either: it removes
// WS_THICKFRAME, and THAT is what Win+Arrow and Aero Snap
// actually key on -- not WS_CAPTION, which this window does
// not have in either arrangement.
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint
| Qt.CustomizeWindowHint)
: (Qt.Window | Qt.FramelessWindowHint)))
// THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the
// flag look broken when it was not.
@@ -369,7 +424,10 @@ ApplicationWindow {
// all stay at their defaults, and nothing would be logged on either
// side. The wizard is the loudest case after QbzBlacklist, because its
// first step ends on "Checking your audio stack…" forever.
QbzDacWizard.boot()
// Linux only: the wizard's row is hidden elsewhere (AudioSettings.qml),
// so booting it would spin up an audio-stack probe nothing can reach.
if (QbzShell.isLinux)
QbzDacWizard.boot()
// Offline Cache Manager. Nothing to seed — the view is unmounted until
// the Settings row opens it — but without this line the manager's very
// first publish is dropped and the view sits on its spinner forever:
@@ -43,7 +43,20 @@ Window {
// clients — there is no such request in the core protocol; it takes a
// compositor policy or a KWin rule, and Qt has nothing to send. The owner
// chose removal over a button that lies.
// WINDOWS ONLY, owner decision D8 (2026-08-27), reversing nothing about
// K1: that ruling removed always-on-top because the owner smoked it on
// real Plasma Wayland and the hint did nothing -- a Wayland client cannot
// raise itself above other clients, there is no such request in the core
// protocol, and Qt had nothing to send. On Windows the same hint IS
// HWND_TOPMOST and does exactly what it says.
//
// So this is a DELIBERATE PER-PLATFORM DIVERGENCE, not a revert: the pin
// exists where the window manager can honour it and stays absent where it
// cannot. Linux and macOS keep the exact flags they had.
property bool pinnedOnTop: false
flags: Qt.Window | Qt.FramelessWindowHint
| ((QbzShell.isWindows && root.pinnedOnTop) ? Qt.WindowStaysOnTopHint : 0)
// Literal, never translated (app.slint:283 uses a plain string, not @tr).
title: "QBZ Mini Player"
@@ -187,7 +187,21 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzMini.exit()
}
// NO PIN. Owner ruling K1 shipped always-on-top as NEW work
// THE PIN, on Windows only (owner decision D8, 2026-08-27).
// `Qt.WindowStaysOnTopHint` is HWND_TOPMOST there and works;
// everything below is why it is absent everywhere else, and
// that reasoning still stands unchanged.
CapBtn {
name: "pin"
visible: QbzShell.isWindows
active: root.hostWindow ? root.hostWindow.pinnedOnTop : false
anchors.verticalCenter: parent.verticalCenter
onClicked: {
if (root.hostWindow)
root.hostWindow.pinnedOnTop = !root.hostWindow.pinnedOnTop
}
}
// NO PIN ELSEWHERE. Owner ruling K1 shipped always-on-top as NEW work
// (the reference promises it in three comments and implements
// it nowhere), and the owner smoked it on real Plasma Wayland
// on 2026-08-04: `Qt.WindowStaysOnTopHint` did not keep the
@@ -613,7 +613,12 @@ Column {
SettingRow {
// Absent while the tray is off — there is no icon to give a
// variant to (owner 2026-08-21).
visible: root.doc.trayEnable === true
//
// And absent on Windows, where the notification area draws the
// executable's own icon: Windows owns light/dark there and the tray
// has no theme arm, so every choice here would render, persist and
// change nothing.
visible: root.doc.trayEnable === true && QbzShell.isWindows !== true
label: QbzSession.tr("Tray icon variant", QbzSession.trRev)
description: QbzSession.tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo", QbzSession.trRev)
QbzSelect {
+34 -2
View File
@@ -150,6 +150,13 @@ Column {
}
}
SettingRow {
// ALSA only. fc0d35e29 opened this to WASAPI expecting plan B Task 6
// (DoP over exclusive mode); the spike then measured this DAC refusing
// 176400, which is the DSD64 DoP carrier, and Task 6 lost its target.
// Both non-convert modes are `cfg(target_os = "linux")` in the audio
// thread and answer "DoP playback is Linux-only" with a stream error,
// so on Windows the row could only ever offer two ways to fail. DSD
// still plays there -- converted to PCM, which is the default.
visible: root.doc.backendIsAlsa === true
label: QbzSession.tr("DSD playback", QbzSession.trRev)
description: QbzSession.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC.", QbzSession.trRev)
@@ -174,6 +181,12 @@ Column {
// Opening it resets the wizard and kicks the audio-stack probe off the UI
// thread, so there is nothing to arm here.
SettingRow {
// The generated config is PipeWire/WirePlumber and qbz-dac-wizard-core
// knows no other stack, so off Linux the wizard cannot finish. Hidden,
// not disabled: a disabled row invites a bug report. This is NOT the
// reference's `backend-is-pipewire` gate coming back - within Linux
// every backend still sees it, which is the owner ruling above.
visible: QbzShell.isLinux
label: QbzSession.tr("HiFi Wizard", QbzSession.trRev)
description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev)
SettingsButton {
@@ -190,16 +203,35 @@ Column {
// =========================== BIT-PERFECT =============================
GroupHeader { text: QbzSession.tr("BIT-PERFECT", QbzSession.trRev) }
SettingRow {
// Hidden where no backend can honour it: on Windows until WASAPI
// exclusive lands (Phase B publishes backendIsWasapi), and on macOS,
// whose only backend is System default. Linux is unchanged - visible
// always, enabled only on ALSA.
//
// WINDOWS: hidden on purpose, even now that Phase B publishes
// `backendIsWasapi`. The exclusive path is chosen by the BACKEND
// dropdown ("WASAPI Exclusive"), not by this toggle -- the selection
// arm in qbz-player keys on `backend_type` and never reads
// `exclusive_mode`. Showing the toggle would put a control under the
// BIT-PERFECT header that changes nothing, which is worse than no
// control at all when the thing being judged IS bit-perfect. Whether
// Windows should instead mirror the Linux split (backend = transport,
// toggle = hw/plughw) is an open design question for the owner.
visible: QbzShell.isLinux
label: QbzSession.tr("Exclusive mode", QbzSession.trRev)
description: QbzSession.tr("Lock the device so no other app can resample it.", QbzSession.trRev)
rowEnabled: root.doc.backendIsAlsa === true
rowEnabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true
QbzToggle {
checked: root.doc.exclusiveMode === true
enabled: root.doc.backendIsAlsa === true
enabled: root.doc.backendIsAlsa === true || root.doc.backendIsWasapi === true
onToggled: function (v) { QbzBridge.settingsBool("exclusive-mode", v) }
}
}
SettingRow {
// D-Bus org.freedesktop.ReserveDevice1, which is Linux only. On
// Windows exclusive mode IS the reservation (Phase B); on macOS it is
// Hog Mode.
visible: QbzShell.isLinux
label: QbzSession.tr("Reserve DAC while running", QbzSession.trRev)
description: QbzSession.tr("Hold the device reserved so other apps can't grab it.", QbzSession.trRev)
QbzToggle {
+12
View File
@@ -929,4 +929,16 @@ Rectangle {
WhatsNewModal {
anchors.fill: parent
}
// A LOADER, not a static mount. `visible: false` still CONSTRUCTS the
// whole subtree -- panel, Flickable, fourteen Repeater delegates, their
// texts and bindings -- on Linux and macOS, where this modal can never be
// shown. The Loader makes that cost real only where the modal exists.
//
// Last of the three so it lands on top at equal z: it is shown once per
// version at startup and has to be answered.
Loader {
anchors.fill: parent
active: QbzShell.isWindows && QbzShell.windowsDisclaimerOpen === true
sourceComponent: WindowsDisclaimerModal { }
}
}
+14 -2
View File
@@ -72,11 +72,23 @@ Item {
// otherwise. Empty when no detail exists (never a bare "CD: ").
readonly property string qualityTooltip: {
if (QbzPlayer.npQualityDetail === "") return ""
if (!showDelivered) return tierLabel + ": " + QbzPlayer.npQualityDetail
if (!showDelivered)
return tierLabel + ": " + QbzPlayer.npQualityDetail + root.streamModeLine
var t = QbzSession.tr("Source: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityDetail)
+ "\n"
+ QbzSession.tr("Output: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityTrueDetail)
return causeLine === "" ? t : t + "\n" + causeLine
t = causeLine === "" ? t : t + "\n" + causeLine
return t + root.streamModeLine
}
// What the OPENED stream negotiated, appended to the tooltip. The mode LED
// beside it is derived from SETTINGS, so it says what was ASKED FOR; this
// says what the engine actually got. When they disagree, that is the whole
// point. Four technical tokens, so nothing new to translate.
readonly property string streamModeLine: {
var m = QbzPlayer.npBitPerfectMode
if (m === "" || m === "unknown") return ""
return "\nstream: " + m
}
readonly property bool localOutput: !QbzPlayer.npIsRemote && !QbzPlayer.npCastActive
+11 -3
View File
@@ -250,9 +250,17 @@ Item {
}
entries.push(
{ "label": nav.trs("Open folder…"), "icon": "folder-open",
"view": "", "tab": "", "action": "openFolder", "enabled": true },
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true },
"view": "", "tab": "", "action": "openFolder", "enabled": true })
// W16: qbz-disc has no Windows CDDA backend -- `mod unsupported`
// compiles and every call returns NoDrive. Offering the entry would
// only ever produce "no drive found" on a machine that has one. SACD
// stays: that opens an image file, which needs no drive.
if (!QbzShell.isWindows) {
entries.push(
{ "label": nav.trs("Open audio CD"), "icon": "disc",
"view": "", "tab": "", "action": "openCd", "enabled": true })
}
entries.push(
{ "label": nav.trs("Open SACD image…"), "icon": "disc-3",
"view": "", "tab": "", "action": "openSacd", "enabled": true })
if (QbzLocal.localEphemeralActive) {
+2 -2
View File
@@ -34,11 +34,11 @@
// MSL 1.2 for Metal, SPIR-V always embedded for Vulkan). Regenerate after
// ANY shader-source edit, from crates/qbz-qt:
// for m in bars wave energy; do \
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 \
// -o qml/assets/shaders/spectrum_$m.frag.qsb \
// qml/assets/shaders/spectrum_$m.frag; \
// done
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// /usr/lib64/qt6/bin/qsb --glsl "100 es,300 es,310 es,320 es,120,150,440" --hlsl 50 --msl 12 -b \
// -o qml/assets/shaders/spectrum.vert.qsb qml/assets/shaders/spectrum.vert
// (macOS: qsb is at $(brew --prefix qt)/bin/qsb.) A .qsb is keyed to the Qt
// Shader Tools version that baked it — rebake when the system Qt moves.
@@ -0,0 +1,216 @@
// The Windows "as-is" disclaimer, shown once per version at startup.
//
// Structure, scrim, shadow, z and the self-gate follow WhatsNewModal.qml,
// which follows LogViewerModal.qml. Colour literals are `Qt.rgba(...)`
// because Slint is #RRGGBBAA and Qt is #AARRGGBB -- the copy that shipped an
// invisible scrim in five modals, fixed 2026-08-14.
//
// ── WHY THE BODY IS NOT TRANSLATED ─────────────────────────────────────────
//
// This is the author's own statement of policy, quoted from the release notes
// ("Windows -- yes, but read this first"). The wording is the point: it says
// what is and is not promised, and in whose voice. The CHROME around it (the
// title, the checkbox, the button) goes through `tr` like everything else, so
// the frame localises and the statement does not get paraphrased by a
// translator into something that promises more or less than it does.
//
// ── WHY THIS IS NOT `Text.MarkdownText` ────────────────────────────────────
//
// Same reason WhatsNewModal gives, minus the parser: the source has exactly
// two constructs, `**bold**` and one link, so the paragraphs below carry
// `<b>` and `<a>` and render as StyledText. No dependency, no subset to
// disagree about.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
anchors.fill: parent
z: 3000
QbzTheme { id: theme }
// The body, verbatim from the release notes. One entry per paragraph.
// `lead: true` renders at the emphasis size -- the two sentences that are
// the actual disclaimer rather than the explanation around it.
readonly property var paragraphs: [
{ lead: false, text: "There is now a Win32 build of QBZ." },
{ lead: false, text: "There is, however, one important distinction:" },
{ lead: true, text: "<b>Windows is not a supported QBZ platform.</b>" },
{ lead: false, text: "The Windows build is provided <b>as-is</b>. Think of it like buying something second-hand: what you see is what you get." },
{ lead: false, text: "I won't promise feature parity, chase Windows-specific regressions, maintain a separate Windows roadmap, or guarantee that something Microsoft changes six months from now won't break it." },
{ lead: false, text: "If it works for you, great. If it doesn't, there is no promise that I'll fix it." },
{ lead: false, text: "And to be completely transparent about why: <b>I hated using Windows while working on this port.</b>" },
{ lead: false, text: "Installing it, configuring the build box, dealing with the environment and especially figuring out its audio setup reminded me why I don't use it in the first place. I have no desire to spend more time than absolutely necessary inside Redmond's OS every time QBZ gets a release." },
{ lead: false, text: "Sorry. Long-time Gentoo Linux user. Some biases are too old to fix." },
{ lead: false, text: "So why ship it?" },
{ lead: false, text: "Because the hard part — getting QBZ running properly on Win32 — is done, and useful code is better in people's hands than sitting in a branch because I personally don't want to maintain another operating system." },
{ lead: false, text: "More importantly, <b>the Windows port is open for adoption</b>." },
{ lead: false, text: "That's essentially what happened with macOS. <a href=\"https://github.com/afonsojramos\">@afonsojramos</a> took ownership of the macOS port and is the reason it grew from an experiment into a properly supported QBZ platform." },
{ lead: false, text: "If someone wants to do the same for Windows, maintain it and eventually remove this rather large disclaimer, get in touch." }
]
property bool remember: false
// Component.onCompleted, NOT onVisibleChanged. `Item.visible` defaults to
// true and the Loader only creates this when it should already be shown,
// so there is no false->true transition to observe: the old handler never
// ran, focus was never taken, Escape never reached the handler below, and
// ordinary hotkeys kept working BEHIND the modal.
Component.onCompleted: keyScope.forceActiveFocus()
// Qt strands activeFocus on an item that is being destroyed, which kills
// AppShell's key dispatcher until the next click. Hand focus back to the
// shell root on the way out -- the same duck-walk QbzConfirmModal uses,
// and for the same measured reason.
Component.onDestruction: root._restoreShellFocus()
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
FocusScope {
id: keyScope
anchors.fill: parent
// Escape closes WITHOUT remembering: dismissing a disclaimer by
// reflex must never be read as having accepted it for good.
Keys.onEscapePressed: QbzShell.dismissWindowsDisclaimer(false)
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.75)
// No click-through close. Unlike the other modals this one is the
// first thing a new user sees and says what is not promised; a stray
// click on the scrim should not dismiss it.
MouseArea { anchors.fill: parent }
}
Rectangle {
anchors.centerIn: panel
width: panel.width + 8
height: panel.height + 8
radius: theme.radiusMd
color: Qt.rgba(0, 0, 0, 0.5)
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(root.width - 80, 720)
height: Math.min(root.height - 80, 640)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Item {
anchors.fill: parent
anchors.margins: 24
Text {
id: headerText
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
text: QbzSession.tr("Windows — yes, but read this first", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 18
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Flickable {
id: scroller
anchors.top: headerText.bottom
anchors.topMargin: 16
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: footerRow.top
anchors.bottomMargin: 16
clip: true
contentWidth: width
contentHeight: body.height
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
width: scroller.width
spacing: 12
Repeater {
model: root.paragraphs
Text {
width: parent.width
text: modelData.text
// StyledText, not RichText: it is the cheap
// subset and it is all these paragraphs use.
textFormat: Text.StyledText
wrapMode: Text.WordWrap
color: modelData.lead ? theme.textPrimary : theme.textSecondary
font.pixelSize: modelData.lead ? 15 : 13
linkColor: theme.accent
onLinkActivated: function (url) { Qt.openUrlExternally(url) }
}
}
}
}
Item {
id: footerRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 32
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzCheckbox {
id: rememberBox
anchors.verticalCenter: parent.verticalCenter
checked: root.remember
// `toggled()` carries NO argument here (see
// QbzCheckbox.qml): the control reports that it was
// hit, and the owner of the state flips it.
onToggled: root.remember = !root.remember
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Don't show this again", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
MouseArea {
anchors.fill: parent
onClicked: root.remember = !root.remember
}
}
}
QbzPrimaryButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
// `label`, not `text` -- this control is not a Qt Button.
label: QbzSession.tr("I understand", QbzSession.trRev)
btnHeight: 32
onClicked: QbzShell.dismissWindowsDisclaimer(root.remember)
}
}
}
}
}
@@ -242,14 +242,19 @@ Item {
CardMenu {
id: openMenu
menuWidth: 220
// W16: the audio-CD row is dropped on Windows -- qbz-disc's CDDA
// backend is `mod unsupported` there and answers NoDrive to
// everything. SACD is an image file, so it stays on every platform.
entries: [
{ "label": QbzSession.tr("Open folder…", QbzSession.trRev),
"icon": "folder-open", "action": "folder" },
"icon": "folder-open", "action": "folder" }
].concat(QbzShell.isWindows ? [] : [
{ "label": QbzSession.tr("Open audio CD", QbzSession.trRev),
"icon": "disc", "action": "cd" },
"icon": "disc", "action": "cd" }
]).concat([
{ "label": QbzSession.tr("Open SACD image…", QbzSession.trRev),
"icon": "disc-3", "action": "sacd" }
]
])
onPicked: function (action) {
if (action === "folder") QbzLocal.ephemeralOpen()
else if (action === "cd") QbzLocal.ephemeralOpenCd()
+4 -1
View File
@@ -352,7 +352,10 @@ fn spawn_header_color(generation: u64, artwork_url: String) {
if path.is_empty() {
return;
}
let path = path.trim_start_matches("file://").to_string();
// NOT trim_start_matches: cached_path returns a REAL file:// URL,
// so on Windows that leaves the `/` before the drive letter
// (`/C:/Users/...`), which opens nothing.
let path = qbz_models::fs_url::local_path(&path);
// One decode, two products: the flat tint (the FALLBACK arm) and the
// blurred atmosphere Slint actually renders behind the header.
let p2 = path.clone();
+4 -1
View File
@@ -359,7 +359,10 @@ pub fn update_for_artwork(artwork_url: &str) {
if path.is_empty() {
return;
}
let path = path.trim_start_matches("file://").to_string();
// NOT trim_start_matches: cached_path returns a REAL file:// URL,
// so on Windows that leaves the `/` before the drive letter
// (`/C:/Users/...`), which opens nothing.
let path = qbz_models::fs_url::local_path(&path);
let triad = tokio::task::spawn_blocking(move || {
let bytes = std::fs::read(&path).ok()?;
let img = image::load_from_memory(&bytes).ok()?;
+7 -20
View File
@@ -219,7 +219,7 @@ pub fn classify(url: &str) -> ArtUrl {
ArtUrl::Plex(fetch)
};
}
if url.starts_with('/') {
if qbz_models::fs_url::is_local_abs_path(url) {
return ArtUrl::LocalFile(url.to_string());
}
ArtUrl::Empty
@@ -240,27 +240,11 @@ pub fn classify(url: &str) -> ArtUrl {
/// result (a tint decode, a copy) for exactly the files the escaping exists to
/// support.
pub fn local_path(url: &str) -> String {
let raw = url.strip_prefix("file://").unwrap_or(url);
raw.replace("%23", "#")
.replace("%3F", "?")
.replace("%25", "%")
qbz_models::fs_url::local_path(url)
}
pub fn file_url(path: &str) -> String {
if path.starts_with("file://") {
return path.to_string();
}
let mut out = String::with_capacity(path.len() + 8);
out.push_str("file://");
for ch in path.chars() {
match ch {
'%' => out.push_str("%25"),
'#' => out.push_str("%23"),
'?' => out.push_str("%3F"),
_ => out.push(ch),
}
}
out
qbz_models::fs_url::file_url(path)
}
// ---------------------------------------------------------------------------
@@ -984,7 +968,10 @@ fn scaled_output_path(path: &str, w: u32, h: u32) -> Option<(String, PathBuf)> {
if w == 0 || h == 0 {
return None;
}
let src = path.strip_prefix("file://").unwrap_or(path).to_string();
// NOT strip_prefix: leaves the `/` before a Windows drive letter, and the
// three percent-escapes, in a string used BOTH as the cache key and as
// the path to open.
let src = qbz_models::fs_url::local_path(path);
let dir = dirs::cache_dir()?.join("qbz").join("images").join("scaled");
let mut hasher = std::collections::hash_map::DefaultHasher::new();
+6 -1
View File
@@ -126,7 +126,12 @@ fn cache_dir() -> Option<PathBuf> {
/// Blocking: decodes and resizes. Call it from `spawn_blocking`, never on the
/// Qt thread.
pub fn for_cover_blocking(cover_path: &str) -> Option<String> {
let src_path = cover_path.strip_prefix("file://").unwrap_or(cover_path);
// NOT strip_prefix: a real file:// URL leaves the `/` before the drive on
// Windows (`/C:/Users/...`), so image::open finds nothing and the header
// atmosphere - and therefore the whole album/artist header gradient -
// silently never appears.
let src_path = qbz_models::fs_url::local_path(cover_path);
let src_path = src_path.as_str();
let dir = cache_dir()?;
// Key on the cover path, like the artwork cache does.
+102 -1
View File
@@ -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![
+10
View File
@@ -1165,6 +1165,16 @@ fn collect_output_sinks() -> (Option<String>, Vec<String>, Option<(String, Strin
/// right now (vs the saved "Preferred Sample Rate"). Linux/PipeWire/Pulse only;
/// `None` when pactl is unavailable (a Flatpak/Snap sandbox without it simply
/// degrades to "—", same as the reference). READ-ONLY.
/// W14, Windows only: `pactl` is a Pulse/PipeWire tool and does not exist on
/// Windows, so the row degrades to the same "-" the missing-pactl path already
/// produced -- without the spawn. macOS and the BSDs keep probing: a Homebrew
/// or ports `pactl` there is real and can answer.
#[cfg(windows)]
fn active_sink_format() -> Option<(String, String)> {
None
}
#[cfg(not(windows))]
fn active_sink_format() -> Option<(String, String)> {
use std::process::Command;
let default = Command::new("pactl")
+6 -2
View File
@@ -622,8 +622,12 @@ impl qbz_icon_tint::QbzIconTint {
}
}
// Absolute paths start with '/', so this yields file:///home/...
QString::from(&format!("file://{}", root.join(&hex).display()))
// NOT format!("file://{path}"): on Windows that yields
// file://C:/... where the drive parses as the URL HOST, and the
// icon renders as an empty rectangle rather than an error.
QString::from(&qbz_models::fs_url::file_url(
&root.join(&hex).to_string_lossy(),
))
}
}
+1 -1
View File
@@ -791,7 +791,7 @@ pub fn set_session_artwork(path: &str) {
// `file://`), because that is the form the whole taxonomy downstream
// expects.
let ids: Vec<u64> = tracks.iter().map(|t| t.id as u64).collect();
let url = format!("file://{path}");
let url = qbz_models::fs_url::file_url(&path);
crate::spawn(async move {
let runtime = crate::app();
if runtime.core().patch_queue_artwork(&ids, &url).await {
+6 -2
View File
@@ -976,9 +976,13 @@ mod tests {
assert!(one.artwork_url.as_deref().unwrap().contains("Disc 01"));
assert!(five.artwork_url.as_deref().unwrap().contains("Disc 05"));
let one_resolved = crate::artwork_qt::cached_path(one.artwork_url.as_deref().unwrap());
// Compare separator-normalised: a `file://` URL is always `/`-separated,
// so the round trip through it cannot hand back a host's backslashes.
// Both forms open the same file on Windows.
let slash = |p: &str| p.replace('\\', "/");
assert_eq!(
crate::artwork_qt::local_path(&one_resolved),
disc_one.join("cover.jpg").to_string_lossy()
slash(&crate::artwork_qt::local_path(&one_resolved)),
slash(&disc_one.join("cover.jpg").to_string_lossy())
);
std::fs::remove_dir_all(&box_dir).unwrap();
+3 -7
View File
@@ -719,13 +719,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
album: album.clone(),
album_version: None,
duration_secs: *duration_secs,
artwork_url: artwork_path.clone().map(|path| {
if path.starts_with("file://") {
path
} else {
format!("file://{path}")
}
}),
artwork_url: artwork_path
.clone()
.map(|path| qbz_models::fs_url::file_url(&path)),
hires: bit_depth.map(|depth| depth >= 24).unwrap_or(false),
bit_depth: *bit_depth,
sample_rate: *sample_rate,
+167
View File
@@ -1,3 +1,9 @@
// No console window beside the GUI on Windows (W4). The file logger
// (`qbz_log::install`) is unaffected; only the inherited stdio console goes.
// If the offscreen smoke ever comes back with an empty log because of this,
// the fix is AttachConsole(ATTACH_PARENT_PROCESS), not reverting this.
#![cfg_attr(windows, windows_subsystem = "windows")]
//! qbz-qt — Qt/QML frontend POC entry point.
//!
//! Phase 1: boot flow (splash -> silent session restore -> shell
@@ -10,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
@@ -386,6 +394,73 @@ mod tray_macos;
// macOS) rather than a gated `mod` line, so the QbzShell invokable that
// calls it needs no cfg of its own.
mod macos_chrome;
// Ungated: the C++ behind it is portable Qt, so every platform
// type-checks the seam even though only Windows uses the handle.
mod win_shell;
/// Flush the session while Windows waits on WM_QUERYENDSESSION.
///
/// BOUNDED, and that is the whole design. Qt blocks inside `commitDataRequest`
/// until this returns, and Windows starts terminating applications that have
/// not answered after roughly five seconds -- so an unbounded save here does
/// not protect the session, it gets the process killed halfway through writing
/// it, which is worse than not trying.
///
/// So the save runs on its own thread and the GUI thread waits on a deadline
/// comfortably under the OS limit. A `tokio::time::timeout` around the future
/// would not do: it cannot preempt the synchronous SQLite section, which is
/// the part that can actually take time.
///
/// If the deadline passes we return anyway and let the session end. The write
/// either lands in the remaining moments or it does not; either way the shell
/// is not left waiting, and the log says which happened.
///
/// `catch_unwind` because a panic must never cross the C ABI back into Qt's
/// signal emission.
#[cfg(target_os = "windows")]
extern "C" fn on_session_commit_data() {
use std::sync::mpsc::channel;
use std::time::Duration;
/// Well under the ~5 s Windows allows before it stops waiting.
const COMMIT_DEADLINE: Duration = Duration::from_secs(3);
let _ = std::panic::catch_unwind(|| {
log::info!("[qbz-qt] session ending; persisting");
let (tx, rx) = channel::<()>();
let spawned = std::thread::Builder::new()
.name("qbz-session-commit".to_string())
.spawn(move || {
// `save_on_exit` block_on()s the capture on the tokio handle
// bound at shell entry. Safe from a plain thread; it would
// panic only from inside an async context, which this is not.
qbz_app::session_persist::save_on_exit();
let _ = tx.send(());
});
if let Err(e) = spawned {
log::warn!("[qbz-qt] could not spawn the session-commit thread: {e}");
return;
}
// Timeout and Disconnected are DIFFERENT and must not share a line: a
// panic in the worker drops the sender, which arrives here as
// Disconnected, and reporting that as "did not finish in time" would
// hide a crash behind a slow-disk story. The outer catch_unwind cannot
// see it -- it guards this thread, not that one.
match rx.recv_timeout(COMMIT_DEADLINE) {
Ok(()) => log::info!("[qbz-qt] session persisted before the session ended"),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => log::warn!(
"[qbz-qt] session persist did not finish within {}s; letting the session end",
COMMIT_DEADLINE.as_secs()
),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
log::error!("[qbz-qt] the session-persist thread died before reporting")
}
}
});
}
// Windows tray. The file carries its own `#![cfg(target_os = "windows")]`,
// matching how tray_macos/tray_linux are declared.
mod tray_windows;
// MPRIS / media keys (owner ruling K3, REVERSED by the owner on 2026-08-04
// after smoking the tray: "no aparece por ejemplo en el widget de now playing
// de KDE Plasma"). Plasma reads MPRIS, so this is what makes the desktop see
@@ -3153,6 +3228,12 @@ fn apply_renderer_preference() {
if cfg!(target_os = "macos") {
log::info!("[renderer] '{choice}' on macOS -> Metal default (the Slint GL remap)");
false
} else if cfg!(target_os = "windows") {
// d3d11 is Qt's healthy default here; desktop GL / ANGLE is a
// downgrade, not the GPU tier this pref means. Same remap as
// macOS: honour the intent (GPU path), not the literal backend.
log::info!("[renderer] '{choice}' on Windows -> d3d11 default (the Slint GL remap)");
false
} else {
std::env::set_var("QSG_RHI_BACKEND", "opengl");
log::info!("[renderer] '{choice}' -> QSG_RHI_BACKEND=opengl");
@@ -3297,6 +3378,15 @@ fn main() {
// user-visible. The child uses its own Wayland connection; fatal protocol
// errors cannot take this parent down.
renderer_qt::preflight_saved_gpu_at_boot();
// 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();
@@ -3398,8 +3488,85 @@ fn main() {
apply_renderer_preference();
apply_scroll_physics();
// FONT ENGINE (Windows). Qt's default rasteriser on Windows renders the
// lyric text thin and jagged - the owner's words were "como fuente de
// bloc de notas con extra alias", and the same text is clean under
// FreeType. Verified on the box by launching with
// QT_QPA_PLATFORM=windows:fontengine=freetype and comparing the lyrics
// panel: same build, same font, only the rasteriser changed.
//
// Only when the variable carries no real value. `offscreen` is what the
// release smoke gate passes here, and overwriting it would make the gate
// test a platform nobody ships. Empty counts as absent: a cleared
// variable is present-with-no-value, which `var_os().is_some()` reports
// as set (the same trap the renderer preference had).
#[cfg(windows)]
{
let explicit = std::env::var_os("QT_QPA_PLATFORM")
.map(|v| !v.to_string_lossy().trim().is_empty())
.unwrap_or(false);
if !explicit {
std::env::set_var("QT_QPA_PLATFORM", "windows:fontengine=freetype");
log::info!("[qbz-qt] font engine -> freetype (Qt's Windows rasteriser aliases text)");
}
}
// The process identity Windows groups the taskbar button under, and the
// one a toast's `app_id` is matched against. Must be set BEFORE any window
// exists -- Windows caches the identity with the first one, and a later
// call is documented to fail.
//
// Without it the taskbar treats each launch as an anonymous window and the
// MSI's Start-menu shortcut (which carries System.AppUserModel.ID) does not
// group with it, and toasts are dropped with no error at all.
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
let id: Vec<u16> = "com.blitzfc.qbz\0".encode_utf16().collect();
// SAFETY: `id` is NUL-terminated UTF-16 and outlives the call, which
// copies it. Called before any window is created, as documented.
let hr = unsafe { SetCurrentProcessExplicitAppUserModelID(id.as_ptr()) };
if hr < 0 {
// Worth a line: when this fails, taskbar grouping and toast
// delivery both break, and both fail SILENTLY. Without this the
// only symptom is "notifications do not work" with nothing to
// point at.
log::warn!("[qbz-qt] SetCurrentProcessExplicitAppUserModelID failed: 0x{hr:08X}");
}
}
// D7 (research/02 §4.4): with no explicit style Windows loads its native
// Controls style, and popups without an explicit `background:` inherit
// native chrome. Pin Basic on Windows so windeployqt ships the style the
// QML was verified against. Preserve Qt's existing platform defaults on
// Linux and macOS. This must run before the first Controls import.
#[cfg(target_os = "windows")]
cxx_qt_lib::QQuickStyle::set_style(&QString::from("Basic"));
let mut app = QGuiApplication::new();
renderer_qt::apply_gpu_preference();
// W25. Windows asks WM_QUERYENDSESSION before it decides to log off or
// shut down, and Qt turns that into `commitDataRequest` and BLOCKS inside
// the signal until the handler returns -- the one moment where a
// synchronous persist is safe. Anything deferred to a queued connection or
// another thread races the process being killed.
//
// WINDOWS ONLY, deliberately, though the hook itself is portable. Qt's
// session manager exists on X11 too, so installing this everywhere would
// add a blocking save to the Linux logout path -- a behaviour change on the
// platform this project leads with, and one that can only ever delay a
// logout. W25 is a Windows contract item; Linux can opt in on its own
// terms, deliberately, rather than inheriting it from a port.
#[cfg(target_os = "windows")]
win_shell::install_commit_data_handler(on_session_commit_data);
// W27's other half. Main.qml asks for CustomizeWindowHint so Qt does not
// paint its own button cluster over the QBZ header; this gives the window
// back a hit test, which that hint otherwise answers as HTNOWHERE for
// every pixel.
#[cfg(target_os = "windows")]
win_shell::install_hittest_filter();
let mut engine = QQmlApplicationEngine::new();
// ── THE APP TYPEFACE, AND WHY IT IS SET RIGHT HERE ────────────────────
+87 -5
View File
@@ -105,12 +105,89 @@ pub(crate) fn init() {
if CONTROLS.get().is_some() {
return;
}
match qbz_media_controls::spawn(dispatch) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
// NON-WINDOWS: unchanged, deliberately. An earlier draft routed every
// platform through the GUI hop and that was a Linux/macOS regression on
// three counts: `tray_bridge::ui` is a NO-OP until `QbzTray::boot`
// registers the Qt thread (`tray_bridge.rs:172`) and `QbzSession.boot()`
// runs first (`Main.qml:254` vs `:380`), so initialization could be
// dropped outright; MPRIS would start only when the Qt queue drained,
// losing the first metadata push; and `main_window_hwnd()` calls
// `QWindow::winId()`, which CREATES the platform window as a side effect
// on platforms that had no reason to.
#[cfg(not(target_os = "windows"))]
{
match qbz_media_controls::spawn(dispatch, qbz_media_controls::NativeWindow::default()) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
}
None => log::info!("[media-controls] no integration on this platform"),
}
None => log::info!("[media-controls] no integration on this platform"),
}
#[cfg(target_os = "windows")]
init_windows();
}
/// Windows needs the main window's HWND before SMTC can register, and it needs
/// to read it on the GUI thread. Both of the things it waits for -- the tray
/// bridge's Qt thread and a shown top-level window -- are "not yet", never
/// "not ever", so this retries on a bounded schedule instead of giving up on
/// the first miss the way a single hop would.
#[cfg(target_os = "windows")]
fn init_windows() {
use std::sync::atomic::{AtomicBool, Ordering};
// Gate on QUEUED, not on CONTROLS: `init` can be called more than once,
// and CONTROLS is only set at the END of a successful spawn, so gating on
// it alone would let several initializers race and build duplicate
// integrations before one wins the OnceLock.
static QUEUED: AtomicBool = AtomicBool::new(false);
if QUEUED.swap(true, Ordering::SeqCst) {
return;
}
// A plain thread, because it is allowed to sleep: this must not occupy a
// tokio worker, and it must not be the GUI thread it is posting to.
let spawned = std::thread::Builder::new()
.name("qbz-smtc-init".to_string())
.spawn(|| {
// 40 x 250 ms = 10 s, comfortably longer than a cold window show.
for _ in 0..40 {
if CONTROLS.get().is_some() {
return;
}
crate::tray_bridge::ui(|_b| {
if CONTROLS.get().is_some() {
return;
}
let native = qbz_media_controls::NativeWindow {
hwnd: crate::win_shell::main_window_hwnd().map(|p| p.as_ptr()),
};
if native.hwnd.is_none() {
// No top-level window yet. Say nothing and let the
// next tick try -- logging here would print forty
// times for a condition that resolves itself.
return;
}
match qbz_media_controls::spawn(dispatch, native) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started (SMTC)");
}
None => log::info!("[media-controls] no integration on this platform"),
}
});
std::thread::sleep(std::time::Duration::from_millis(250));
}
if CONTROLS.get().is_none() {
log::warn!(
"[media-controls] SMTC never started: no top-level window after 10 s"
);
}
});
if let Err(e) = spawned {
log::warn!("[media-controls] could not spawn the SMTC init thread: {e}");
}
}
@@ -140,6 +217,10 @@ pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, albu
// smaller set of users.
maybe_notify(track, title, album);
push_metadata(track, title, album);
// BEFORE the early return, and for the same reason `maybe_notify` is:
// keeping the machine awake has nothing to do with whether the OS media
// integration started, and a track is about to play either way.
qbz_media_controls::set_sleep_inhibit(true);
let Some(mc) = handle() else {
return;
};
@@ -223,6 +304,7 @@ pub(crate) fn clear_now_playing() {
/// transitions the MPRIS `Position` getter answers from the last value pushed
/// here — the same behaviour as the Slint build.
pub(crate) fn push_playback_state(is_playing: bool, position_secs: u64) {
qbz_media_controls::set_sleep_inhibit(is_playing);
let Some(mc) = handle() else {
return;
};
+3 -1
View File
@@ -1470,7 +1470,9 @@ fn resolve_from_tracks(item: &MixtapeCollectionItem, tracks: &[QueueTrack]) -> R
// artwork channel reads a bare filesystem path.
let artwork_url = first
.and_then(|t| t.artwork_url.clone())
.map(|u| u.strip_prefix("file://").map(str::to_string).unwrap_or(u))
// NOT strip_prefix: the artwork channel wants a bare filesystem path,
// and on Windows a real file:// URL strips to `/C:/Users/...`.
.map(|u| qbz_models::fs_url::local_path(&u))
.unwrap_or_default();
let artist_id = first
+13 -2
View File
@@ -67,6 +67,10 @@ pub struct NowPlayingModel {
pub quality_tier: String,
/// CATALOG detail, e.g. "24-bit / 96 kHz".
pub quality_detail: String,
/// The OPENED STREAM's bit-perfect mode, straight from the engine:
/// "direct" | "plugin" | "disabled" | "unknown". Never derived from
/// settings - that is what the mode LED already does.
pub bit_perfect_mode: String,
/// Delivered-vs-catalog block (downgrade arrow + tooltip cause).
pub delivered: Delivered,
/// Last delivered stream params seen from the engine, so a poll tick that
@@ -160,6 +164,8 @@ fn publish(m: &NowPlayingModel) {
b.as_mut().set_np_quality_detail(detail.clone());
// Legacy alias of the same value (pre-contract Qt name).
b.as_mut().set_np_quality_label(detail);
b.as_mut()
.set_np_bit_perfect_mode(QString::from(m.bit_perfect_mode.as_str()));
b.as_mut().set_np_quality_downgraded(m.delivered.downgraded);
b.as_mut()
.set_np_quality_true_detail(QString::from(m.delivered.true_detail.as_str()));
@@ -377,6 +383,7 @@ pub fn set_track(meta: TrackMeta) {
m.cache = 0.0;
m.quality_tier = meta.quality_tier;
m.quality_detail = meta.quality_label;
m.bit_perfect_mode = String::new();
m.delivered = Delivered::default();
m.eff_rate_hz = 0;
m.eff_bits = 0;
@@ -405,13 +412,17 @@ pub fn set_catalog_quality(bit_depth: Option<u32>, sample_rate: Option<f64>, gov
/// (`sample_rate` / `bit_depth`; 0 = not reported yet). Re-evaluates the
/// downgrade block and republishes ONLY when the params actually moved, so a
/// steady stream costs one atomic compare per tick and no Qt hop.
pub fn set_effective_stream(eff_rate_hz: u32, eff_bits: u32) {
pub fn set_effective_stream(eff_rate_hz: u32, eff_bits: u32, bit_perfect: &str) {
let (changed, snapshot) = with_model(|m| {
if m.eff_rate_hz == eff_rate_hz && m.eff_bits == eff_bits {
if m.eff_rate_hz == eff_rate_hz
&& m.eff_bits == eff_bits
&& m.bit_perfect_mode == bit_perfect
{
return false;
}
m.eff_rate_hz = eff_rate_hz;
m.eff_bits = eff_bits;
m.bit_perfect_mode = bit_perfect.to_string();
m.delivered = crate::quality_state::evaluate(eff_rate_hz, eff_bits);
true
});
+7 -9
View File
@@ -377,21 +377,19 @@ pub fn remove_album(album_id: String) {
/// Open the cache directory in the desktop file manager (Slint `open_folder`).
///
/// `xdg-open` / `open` by hand rather than the `rfd` used elsewhere in the
/// port: rfd opens a PICKER, and this row is "show me the folder". Same two
/// binaries the reference shells out to.
/// Not the `rfd` used elsewhere in the port: rfd opens a PICKER, and this row
/// is "show me the folder". Routed through the `open` crate like the other
/// eleven sites in this crate — the hand-rolled `xdg-open` fallback ran on
/// every non-macOS host, Windows included, where it does not exist. `open`
/// uses ShellExecuteW there and xdg-open/open on Linux/macOS, so the two
/// supported platforms behave exactly as before.
pub fn open_folder() {
crate::spawn(async move {
let Some(off) = offline_qt::get().await else {
return;
};
let path = off.get_cache_path();
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() {
if let Err(e) = open::that(&path) {
log::warn!("[qbz-qt] open offline folder failed: {e}");
}
});
+11
View File
@@ -57,6 +57,7 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels {
Some(AudioBackendType::Alsa) => ("ALSA", true),
Some(AudioBackendType::Jack) => ("JACK", true),
Some(AudioBackendType::Pulse) => ("PULS", true),
Some(AudioBackendType::WasapiExclusive) => ("WASAPI", true),
Some(AudioBackendType::SystemDefault) => ("SYST", false),
None => ("AUTO", false),
};
@@ -88,6 +89,16 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels {
}
}
Some(AudioBackendType::Pulse) => ("SHARED", false),
// Exclusive needs a CHOSEN device: with none the player falls through
// to the shared stream, and lighting EXCL there would be the LED
// lying, which is the one thing these two must never do.
Some(AudioBackendType::WasapiExclusive) => {
if audio.output_device.is_some() {
("EXCL", true)
} else {
("SHARED", false)
}
}
Some(AudioBackendType::SystemDefault) | None => ("DEFAULT", false),
};
OutputLabels {
+12 -7
View File
@@ -1217,13 +1217,7 @@ async fn offline_album_queue(album_id: &str) -> Result<Vec<QueueTrack>, String>
let artwork = local
.and_then(|row| row.artwork_path.clone())
.or_else(|| cached.resolve_cover_path(&cache_path))
.map(|path| {
if path.starts_with("file://") {
path
} else {
format!("file://{path}")
}
});
.map(|path| qbz_models::fs_url::file_url(&path));
let album = cached.album.clone().unwrap_or_default();
let sample_rate = cached
.sample_rate
@@ -3443,9 +3437,20 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
// DELIVERED stream params -> the downgrade arrow + tooltip cause.
// Deduped inside, so a steady stream costs no Qt-thread hop.
// The mode the STREAM negotiated, not the one the settings asked
// for. `output_labels.rs` already derives the LED from settings;
// this is the only value that can contradict it, which is exactly
// what makes it worth publishing.
let bit_perfect = match event.bit_perfect_mode {
Some(qbz_audio::BitPerfectMode::DirectHardware) => "direct",
Some(qbz_audio::BitPerfectMode::PluginFallback) => "plugin",
Some(qbz_audio::BitPerfectMode::Disabled) => "disabled",
None => "unknown",
};
crate::now_playing::set_effective_stream(
event.sample_rate.unwrap_or(0),
event.bit_depth.unwrap_or(0),
bit_perfect,
);
// --- Gapless prefetch trigger (playback.rs:5362-5476) ---------
+8
View File
@@ -71,6 +71,12 @@ pub mod qbz_player {
// Legacy alias of np_quality_detail (the pre-contract Qt name) —
// published from the same value so existing QML keeps working.
#[qproperty(QString, np_quality_label)]
// What the OPENED STREAM actually negotiated: "direct" | "plugin" |
// "disabled" | "unknown". Distinct from the mode LED on purpose - that
// one is derived from SETTINGS (output_labels.rs), so it says what was
// ASKED FOR. Only this one can tell "exclusive worked" from "we asked
// for exclusive", which is what phase B's acceptance turns on.
#[qproperty(QString, np_bit_perfect_mode)]
// DELIVERED-vs-catalog state (#590/#638). While `downgraded` is on,
// the stamp's main line reports the delivered tier/detail and the
// catalog max moves to the tooltip's "Source" line.
@@ -300,6 +306,7 @@ pub struct QbzPlayerRust {
np_quality_tier: QString,
np_quality_detail: QString,
np_quality_label: QString,
np_bit_perfect_mode: QString,
np_quality_downgraded: bool,
np_quality_true_detail: QString,
np_quality_effective_tier: QString,
@@ -355,6 +362,7 @@ impl Default for QbzPlayerRust {
np_repeat_mode: 0,
np_quality_detail: QString::default(),
np_quality_label: QString::default(),
np_bit_perfect_mode: QString::default(),
np_quality_downgraded: false,
np_quality_true_detail: QString::default(),
np_quality_effective_tier: QString::default(),
+7 -11
View File
@@ -71,18 +71,14 @@ pub fn resolve_qconnect_device_type() -> i32 {
}
pub fn resolve_system_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME") {
if !h.trim().is_empty() {
return h.trim().to_string();
}
// One implementation, in qbz-app: this copy read HOSTNAME then
// /etc/hostname, neither of which Windows has, so every Windows install
// announced itself with the same name. The "Desktop" last resort is kept
// here rather than pushed down: qbzd's differs, and both are user-visible.
match qbz_app::settings::bundle::hostname().as_str() {
"unknown" => "Desktop".to_string(),
h => h.to_string(),
}
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let trimmed = h.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
"Desktop".to_string()
}
/// Returns "Qbz - {hostname}" as the default device name.
+139
View File
@@ -412,6 +412,100 @@ fn update_prefs(edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value
let _ = edit_prefs(|doc| (edit(doc), ()));
}
/// Can this install actually deliver a toast?
///
/// Setting the process AUMID is necessary and NOT sufficient. An unpackaged
/// desktop app only receives toasts once Windows can RESOLVE that id, which it
/// does through either a Start-menu shortcut carrying
/// `System.AppUserModel.ID` or the `AppUserModelId` registry key. The MSI
/// writes both; a portable unzip has neither.
///
/// So the notifications row is offered on the strength of THIS, not of
/// `cfg!(windows)`. Advertising it unconditionally would put a switch in front
/// of portable users that can never produce a notification -- the "renders,
/// persists and drives nothing" failure the capability flags exist to prevent.
#[cfg(target_os = "windows")]
pub(crate) fn windows_toast_identity_registered() -> bool {
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
};
let key: Vec<u16> = "Software\\Classes\\AppUserModelId\\com.blitzfc.qbz\0"
.encode_utf16()
.collect();
let mut handle: HKEY = std::ptr::null_mut();
// SAFETY: `key` is NUL-terminated UTF-16 and outlives the call; `handle` is
// a valid out-slot, only written on success. Read-only access.
let rc = unsafe {
RegOpenKeyExW(
HKEY_CURRENT_USER,
key.as_ptr(),
0,
KEY_READ,
&mut handle,
)
};
if rc != 0 {
return false;
}
// SAFETY: opened above and not used afterwards.
unsafe {
RegCloseKey(handle);
}
true
}
/// The Windows as-is disclaimer, kept as two fields (owner's call): the
/// "Don't show this again" box and the version it was ticked on.
pub(crate) const WINDOWS_DISCLAIMER_HIDDEN_KEY: &str = "windows_disclaimer_hidden";
pub(crate) const WINDOWS_DISCLAIMER_VERSION_KEY: &str = "windows_disclaimer_ack_version";
/// Both fields out of ONE parsed document.
///
/// Reading them with two `pref_*` calls would parse the file twice and could
/// synthesise a pair that never existed in a single snapshot -- the shared
/// `ui_prefs.json` has another writer (the Slint app) and this one is read at
/// construction, while it is most likely to be mid-write.
pub(crate) fn windows_disclaimer_state() -> (bool, String) {
let Some(path) = prefs_path() else {
return (false, String::new());
};
let Some(doc) = read_json_object(&path) else {
return (false, String::new());
};
let hidden = doc
.get(WINDOWS_DISCLAIMER_HIDDEN_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
let version = doc
.get(WINDOWS_DISCLAIMER_VERSION_KEY)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(hidden, version)
}
/// Both fields into ONE transaction, and report whether it landed.
///
/// Two `save_pref` calls would be two whole-document read-modify-writes, and a
/// crash or a failed rename between them leaves a torn pair -- `hidden` with no
/// version, or a version with no `hidden`. Either half alone re-opens the
/// modal, so the user would tick the box and see it again.
pub(crate) fn save_windows_disclaimer_ack(version: &str) -> bool {
edit_prefs(|doc| {
doc.insert(
WINDOWS_DISCLAIMER_HIDDEN_KEY.to_string(),
serde_json::Value::Bool(true),
);
doc.insert(
WINDOWS_DISCLAIMER_VERSION_KEY.to_string(),
serde_json::Value::String(version.to_string()),
);
(true, ())
})
.is_some()
}
/// Flip a shared bool pref in ONE document — read and write inside the same
/// read-modify-write, never `pref_bool` then `save_pref`. `None` = the
/// document was unreadable, so nothing was written and nothing flipped.
@@ -1605,6 +1699,11 @@ pub struct SettingsDoc {
pub backend_index: i32,
#[serde(rename = "backendIsAlsa")]
pub backend_is_alsa: bool,
/// True when the WASAPI exclusive backend is the active one. AudioSettings
/// QML gates the Exclusive-mode and DSD rows on it - see the comment there
/// for why they are hidden rather than disabled.
#[serde(rename = "backendIsWasapi")]
pub backend_is_wasapi: bool,
#[serde(rename = "backendIsPipewire")]
pub backend_is_pipewire: bool,
#[serde(rename = "backendIsJack")]
@@ -1828,6 +1927,20 @@ pub struct SettingsDoc {
/// `AppearanceState.is-macos`.
#[serde(rename = "isMacos")]
pub is_macos: bool,
#[serde(rename = "isWindows")]
pub is_windows: bool,
/// A tray backend exists on this platform (`tray_qt::init` would create
/// one). Phase A: Linux + macOS. Plan A-2 Task 5 adds Windows.
#[serde(rename = "traySupported")]
pub tray_supported: bool,
/// `notify.rs` has a real arm here. Phase A: Linux + macOS. Plan A-2
/// Task 3 adds Windows.
#[serde(rename = "systemNotificationsSupported")]
pub system_notifications_supported: bool,
/// The RENDERER group offers a real choice: Linux (Vulkan/GL/software)
/// and Windows (D3D11/D3D12/GL/software). macOS is always Metal.
#[serde(rename = "rendererSelectable")]
pub renderer_selectable: bool,
#[serde(rename = "trayIconThemes")]
pub tray_icon_themes: Vec<String>,
#[serde(rename = "trayIconThemeIndex")]
@@ -2017,6 +2130,8 @@ fn backend_label(t: AudioBackendType) -> String {
AudioBackendType::Pulse => "PulseAudio".to_string(),
AudioBackendType::SystemDefault => qbz_i18n::t("System default"),
AudioBackendType::Jack => "JACK".to_string(),
// Not translated, like the other backend proper nouns above it.
AudioBackendType::WasapiExclusive => "WASAPI Exclusive".to_string(),
}
}
@@ -2100,6 +2215,7 @@ pub async fn publish_snapshot() {
.collect(),
backend_index: backend_index as i32 + 1,
backend_is_alsa: active_backend == AudioBackendType::Alsa,
backend_is_wasapi: active_backend == AudioBackendType::WasapiExclusive,
backend_is_pipewire: active_backend == AudioBackendType::PipeWire,
backend_is_jack: active_backend == AudioBackendType::Jack,
devices,
@@ -2273,6 +2389,29 @@ pub async fn publish_snapshot() {
.map(|t| t.mac_hide_dock)
.unwrap_or(false),
is_macos: cfg!(target_os = "macos"),
is_windows: cfg!(target_os = "windows"),
// Windows joined with A-2 T5 (Shell_NotifyIconW).
tray_supported: cfg!(any(
target_os = "linux",
target_os = "macos",
target_os = "windows"
)),
// Windows answers at RUNTIME, not by cfg: the toast arm is
// implemented, but delivery needs a registered AppUserModelID,
// which only the MSI provides. A portable unzip gets `false` and
// the row stays hidden rather than offering a switch that can
// never produce a notification.
system_notifications_supported: {
#[cfg(target_os = "windows")]
{
windows_toast_identity_registered()
}
#[cfg(not(target_os = "windows"))]
{
cfg!(any(target_os = "linux", target_os = "macos"))
}
},
renderer_selectable: cfg!(any(target_os = "linux", target_os = "windows")),
tray_icon_themes: TRAY_ICON_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
tray_icon_theme_index: tray()
.get_settings()
+83
View File
@@ -206,6 +206,23 @@ pub mod qbz_shell {
// `cfg!`, and AppearanceSettings.slint:1011-1043 gates the whole
// group AND the Preferred-GPU row on it).
#[qproperty(bool, is_linux)]
// Windows is neither of the two above, and QML must be able to say
// so: without it every two-way `isMacos ? ... : ...` ternary hands
// Windows the LINUX arm by default. Compile-time, like both others.
#[qproperty(bool, is_windows)]
// The Windows as-is disclaimer. True while the modal should be up.
//
// Seeded at CONSTRUCTION, like the window geometry above and for the
// same reason: Main.qml binds a modal to it and the first frame is
// already too late for a post-boot push.
//
// What it is seeded FROM is a version string, not a boolean. "Don't
// show again" stores the version that was acknowledged, so a build
// whose version differs from the stored one shows it again with no
// separate reset step -- the reset IS the comparison. Dismissing
// WITHOUT the box ticked only clears this property, so the next launch
// asks once more.
#[qproperty(bool, windows_disclaimer_open)]
// --- Main-window geometry -----------------------------------------
// The restored LOGICAL size + maximized flag from the shared
// ui_prefs.json (settings_qt::window_size / window_maximized; the
@@ -453,6 +470,12 @@ pub mod qbz_shell {
#[qinvokable]
fn report_renderer_api(self: Pin<&mut QbzShell>, api: QString);
/// Close the Windows disclaimer. `remember` = the "Don't show this
/// again" box was ticked, which persists the CURRENT version as
/// acknowledged; the next release therefore shows it again.
#[qinvokable]
fn dismiss_windows_disclaimer(self: Pin<&mut QbzShell>, remember: bool);
/// The frame-liveness watchdog's verdict (PARITY-DEBT #104's other
/// owed half). Reported once by `Main.qml` after a settling window,
/// with the number of frames the window actually swapped in it.
@@ -790,6 +813,8 @@ pub struct QbzShellRust {
invert_swipe_navigation: bool,
is_macos: bool,
is_linux: bool,
is_windows: bool,
windows_disclaimer_open: bool,
window_width: f32,
window_height: f32,
window_maximized: bool,
@@ -894,6 +919,8 @@ impl Default for QbzShellRust {
// the same `cfg!` (main.rs seeds AppearanceState at startup).
is_macos: cfg!(target_os = "macos"),
is_linux: cfg!(target_os = "linux"),
is_windows: cfg!(target_os = "windows"),
windows_disclaimer_open: crate::shell_bridge::windows_disclaimer_pending(),
window_width,
window_height,
window_maximized: crate::settings_qt::window_maximized(),
@@ -1083,6 +1110,39 @@ pub fn multi_select_capable() -> bool {
MULTI_SELECT_CAPABLE.load(std::sync::atomic::Ordering::SeqCst)
}
/// The Windows as-is disclaimer, stored in `ui_prefs.json` as TWO fields
/// (owner's call, 2026-08-27):
///
/// - `windows_disclaimer_hidden` -- the "Don't show this again" box;
/// - `windows_disclaimer_ack_version` -- the version it was ticked on.
///
/// Two rather than one so the boolean means something on its own: a future
/// "show it again" control can flip it without inventing a version to write,
/// and reading the file tells you both what the user chose and when.
///
/// KNOWN AND ACCEPTED: reinstalling the SAME version does not bring the modal
/// back, because `ui_prefs.json` lives in %APPDATA% and outlives the
/// installer. A version CHANGE does bring it back, which is the requirement.
/// If the same-version case ever matters, the MSI is the place to clear these
/// two keys.
/// Should the Windows as-is disclaimer be shown for THIS build?
///
/// Shown unless the user hid it AND hid it on this exact version. A newer
/// build therefore shows it again with nothing to reset by hand.
///
/// Both fields come out of ONE parsed document; the keys and the write live in
/// `settings_qt`, which owns `ui_prefs.json`'s read-modify-write discipline.
///
/// Windows only. On every other platform the modal does not exist, and this
/// answers false without touching the prefs file.
pub(crate) fn windows_disclaimer_pending() -> bool {
if !cfg!(target_os = "windows") {
return false;
}
let (hidden, acked) = crate::settings_qt::windows_disclaimer_state();
!hidden || acked != env!("CARGO_PKG_VERSION")
}
impl qbz_shell::QbzShell {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
@@ -1105,6 +1165,29 @@ impl qbz_shell::QbzShell {
/// `renderer_qt::reduce_motion` rather than assigning the tier directly —
/// the kiosk toggle writes the same property, and a bare assignment from
/// either side would erase the other's contribution.
/// Close the Windows disclaimer, optionally for this version.
///
/// `remember == false` clears the property only: the modal is gone for
/// this run and comes back on the next launch, which is what a plain
/// "Close" should mean.
pub fn dismiss_windows_disclaimer(mut self: Pin<&mut Self>, remember: bool) {
self.as_mut().set_windows_disclaimer_open(false);
if !remember {
return;
}
// One transaction, and the log follows the RESULT: `save_pref` reports
// nothing, so an unconditional "acknowledged" line would claim a write
// that never happened whenever the document was unreadable.
if crate::settings_qt::save_windows_disclaimer_ack(env!("CARGO_PKG_VERSION")) {
log::info!(
"[qbz-qt] Windows disclaimer acknowledged for v{}",
env!("CARGO_PKG_VERSION")
);
} else {
log::warn!("[qbz-qt] could not persist the Windows disclaimer acknowledgement");
}
}
pub fn report_renderer_api(mut self: Pin<&mut Self>, api: QString) {
let api = api.to_string();
if !crate::renderer_qt::set_active_api(&api) {
+297
View File
@@ -0,0 +1,297 @@
//! 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();
}
}
+7
View File
@@ -180,8 +180,15 @@ impl qbz_tray::QbzTray {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] tray Qt thread already registered");
}
// If `tray_qt::init` ran before this point its queued closure was
// dropped on the floor -- `ui()` above is a no-op until the line
// right here. Pick the work up now; we are already on the GUI thread.
#[cfg(target_os = "windows")]
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.
+92 -3
View File
@@ -5,8 +5,8 @@
//! The ksni half lives in `tray_linux.rs` (A-20); the QObject QML talks to is
//! `tray_bridge.rs` (A-21).
//!
//! Linux (ksni, `tray_linux.rs`) and macOS (`NSStatusItem`, `tray_macos.rs`).
//! Windows has no tray in either tree.
//! Linux uses ksni (`tray_linux.rs`), macOS uses NSStatusItem
//! (`tray_macos.rs`), and Windows uses Shell_NotifyIcon (`tray_windows.rs`).
//!
//! RETIRED 2026-08-05 — the note that used to sit here said macOS "would be a
//! NEW implementation, not a port", because the reference only hand-rolls its
@@ -128,6 +128,11 @@ pub(crate) struct TrayHandle {
linux: Option<crate::tray_linux::LinuxTrayHandle>,
#[cfg(target_os = "macos")]
macos: bool,
// Same shape as the macOS flag and for the same reason: the icon lives in
// C++ process-global state, so this only records that a tray EXISTS. It is
// what `handle().is_some()` -- and therefore close-to-tray -- consults.
#[cfg(target_os = "windows")]
windows: bool,
}
impl TrayHandle {
@@ -137,6 +142,29 @@ impl TrayHandle {
h.set_track(title, artist, album);
return;
}
#[cfg(target_os = "windows")]
if self.windows {
// The tray's hover text is the Windows analogue of the Linux
// menu header: title on the first line, artist/album below.
//
// Through the GUI hop, exactly as the macOS theme arm below. This
// publisher runs on whatever thread playback pushed from -- the
// 1 Hz poll lives in `crate::spawn` -- while the Windows tray keeps
// process-global C++ state and talks to a window owned by the GUI
// thread. Calling straight through was a data race, not merely a
// documentation mismatch. Linux needs no hop because ksni's handle
// is a thread-safe channel.
let desc = match (artist.is_empty(), album.is_empty()) {
(true, true) => String::new(),
(true, false) => album.clone(),
(false, true) => artist.clone(),
(false, false) => format!("{artist} - {album}"),
};
crate::tray_bridge::ui(move |_b| {
crate::tray_windows::set_tooltip(&title, &desc);
});
return;
}
#[cfg(not(target_os = "linux"))]
let _ = (title, artist, album);
}
@@ -146,6 +174,10 @@ impl TrayHandle {
if let Some(h) = &self.linux {
h.clear_track();
}
#[cfg(target_os = "windows")]
if self.windows {
crate::tray_bridge::ui(|_b| crate::tray_windows::set_tooltip("QBZ", ""));
}
}
pub(crate) fn set_playing(&self, is_playing: bool) {
@@ -154,6 +186,11 @@ impl TrayHandle {
h.set_playing(is_playing);
return;
}
#[cfg(target_os = "windows")]
if self.windows {
crate::tray_bridge::ui(move |_b| crate::tray_windows::set_playing(is_playing));
return;
}
#[cfg(not(target_os = "linux"))]
let _ = is_playing;
}
@@ -170,6 +207,10 @@ impl TrayHandle {
crate::tray_bridge::ui(move |_b| crate::tray_macos::set_icon_theme(&theme));
return;
}
// Windows has no arm on purpose: the notification area draws the
// executable's own RT_GROUP_ICON and Windows owns light/dark itself,
// so there is nothing for a theme choice to change. The settings row
// stays hidden there rather than offering a control that does nothing.
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let _ = theme;
}
@@ -373,13 +414,61 @@ pub(crate) fn init(theme_override: String, enabled: bool) {
});
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
// Windows: Shell_NotifyIconW owns a hidden window with a message loop,
// so the icon must be created on the thread that pumps it -- the Qt GUI
// thread.
//
// The hop ALONE is not enough, and the first version of this shipped with
// exactly that bug: `tray_bridge::ui` silently discards work until
// `QbzTray::boot` registers the Qt thread, and this runs from
// `on_session_entered`, which reaches it first. The symptom was a tray that
// never appeared and a log with no `[tray]` line at all -- not even the
// "disabled by user setting" the gates print when they refuse, which is
// what pinned it down.
//
// So: record the intent, then try. Whichever of the two arrives SECOND --
// this call or `boot` -- does the work, and `create_now` is idempotent.
// No polling, and nothing to time out.
#[cfg(target_os = "windows")]
{
let _ = theme_override;
TRAY_WANTED.store(true, Ordering::SeqCst);
crate::tray_bridge::ui(|_b| create_now());
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = theme_override;
log::info!("[tray] no tray backend on this platform");
}
}
/// Set once `init` has decided a tray SHOULD exist. Read by
/// `QbzTray::boot`, which creates it if `init` ran before the Qt thread was
/// registered and its queued closure went nowhere.
#[cfg(target_os = "windows")]
static TRAY_WANTED: AtomicBool = AtomicBool::new(false);
/// Create the Windows tray. GUI THREAD ONLY, and idempotent: both `init`'s
/// hop and `QbzTray::boot` call it, and whichever runs first wins.
#[cfg(target_os = "windows")]
pub(crate) fn create_now() {
if !TRAY_WANTED.load(Ordering::SeqCst) || TRAY.get().is_some() {
return;
}
let ok = crate::tray_windows::create();
// Both arms publish, exactly as Linux and macOS do: a failure MUST report
// false, or close-to-tray hides the window into a tray that is not there.
if ok {
let _ = TRAY.set(TrayHandle { windows: true });
push_tray_live(true);
log::info!("[tray] Windows notification-area icon created");
} else {
log::error!("[tray] Windows notification-area icon could not be created");
push_tray_live(false);
}
}
/// True when running under gamescope (Steam Deck Game Mode). Stateless, and a
/// verbatim port of `crates/qbz/src/main.rs:8333-8336` — the raw
/// `GAMESCOPE_WAYLAND_DISPLAY` env is inherited by a re-spawn, and
+149
View File
@@ -0,0 +1,149 @@
//! Windows notification-area tray, backed by `cxx/win_tray.cpp`.
//!
//! The sibling of `tray_linux.rs` (StatusNotifierItem) and `tray_macos.rs`
//! (NSStatusItem). All three drive the same `tray_qt` dispatchers and, since
//! this one reuses their five `qbz_i18n` keys, present the same menu.
//!
//! GUI THREAD ONLY. The icon owns a hidden window with a message loop, so a
//! call from tokio would post to a thread that never pumps.
#![cfg(target_os = "windows")]
unsafe extern "C" {
fn qbz_win_tray_set_labels(
play_pause: *const u16,
next: *const u16,
previous: *const u16,
show_hide: *const u16,
quit: *const u16,
);
fn qbz_win_tray_set_callbacks(
on_left_click: extern "C" fn(),
on_play_pause: extern "C" fn(),
on_next: extern "C" fn(),
on_previous: extern "C" fn(),
on_quit: extern "C" fn(),
);
fn qbz_win_tray_create(tooltip: *const u16) -> bool;
fn qbz_win_tray_set_tooltip(tooltip: *const u16);
fn qbz_win_tray_set_playing(playing: bool);
fn qbz_win_tray_destroy();
}
/// NUL-terminated UTF-16, the only string shape the Win32 `W` APIs take.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Truncate to at most `max_units` UTF-16 CODE UNITS, never mid-character.
///
/// The Win32 buffers are sized in `wchar_t`, i.e. UTF-16 units, and an emoji
/// or any astral character costs two of them. Bounding by `chars()` counts
/// scalar values instead, so a 120-char string can be 240 units; `wcsncpy_s`
/// with `_TRUNCATE` then cuts at the unit boundary and can keep a HIGH
/// SURROGATE with its partner gone -- malformed UTF-16 handed to the shell.
///
/// Stepping whole `char`s and paying `len_utf16()` for each cannot split a
/// pair, because a pair is one `char` here.
fn truncate_utf16(s: &str, max_units: usize) -> String {
let mut out = String::new();
let mut units = 0usize;
for ch in s.chars() {
let n = ch.len_utf16();
if units + n > max_units {
break;
}
out.push(ch);
units += n;
}
out
}
// The C ABI callbacks. Each is the same dispatcher the Linux and macOS menus
// call, so a tray action behaves identically on all three.
extern "C" fn on_left_click() {
crate::tray_qt::present();
}
extern "C" fn on_play_pause() {
crate::tray_qt::dispatch_play_pause();
}
extern "C" fn on_next() {
crate::tray_qt::dispatch_next();
}
extern "C" fn on_previous() {
crate::tray_qt::dispatch_previous();
}
extern "C" fn on_quit() {
crate::tray_qt::quit();
}
/// Create the icon. `false` means no tray exists, and the caller MUST leave
/// the handle empty when it does: `should_hide_on_close` consults that handle,
/// so pretending a failed tray succeeded makes close-to-tray hide the window
/// with nothing left to bring it back.
pub(crate) fn create() -> bool {
// Labels first -- the C side copies them, and the menu is built on demand
// from whatever it last received.
// The C side holds each label in a 64-unit buffer, so 63 plus the
// terminator. Truncated HERE, where char boundaries are known, rather than
// by `wcsncpy_s` in C, which counts units and would happily halve a
// surrogate pair.
const LABEL_UNITS: usize = 63;
let play_pause = wide(&truncate_utf16(&qbz_i18n::t("Play/Pause"), LABEL_UNITS));
let next = wide(&truncate_utf16(&qbz_i18n::t("Next Track"), LABEL_UNITS));
let previous = wide(&truncate_utf16(&qbz_i18n::t("Previous Track"), LABEL_UNITS));
let show_hide = wide(&truncate_utf16(
&qbz_i18n::t("Show/Hide Window"),
LABEL_UNITS,
));
let quit = wide(&truncate_utf16(&qbz_i18n::t("Quit QBZ"), LABEL_UNITS));
// SAFETY: five NUL-terminated UTF-16 buffers, alive across the call, which
// copies them into its own storage.
unsafe {
qbz_win_tray_set_labels(
play_pause.as_ptr(),
next.as_ptr(),
previous.as_ptr(),
show_hide.as_ptr(),
quit.as_ptr(),
);
}
// SAFETY: all five are `extern "C"` fns with no captured state, valid for
// the life of the process.
unsafe {
qbz_win_tray_set_callbacks(on_left_click, on_play_pause, on_next, on_previous, on_quit);
}
let tooltip = wide("QBZ");
// SAFETY: NUL-terminated and alive across the call.
unsafe { qbz_win_tray_create(tooltip.as_ptr()) }
}
/// Hover text, bounded to what `szTip` holds: 128 UTF-16 units including the
/// terminator.
pub(crate) fn set_tooltip(title: &str, desc: &str) {
let joined = if desc.is_empty() {
title.to_string()
} else {
format!("{title}\n{desc}")
};
// szTip is 128 units INCLUDING the terminator. Track titles are full of
// non-ASCII and occasionally astral characters, which is exactly where a
// unit-counting truncation goes wrong.
let s = truncate_utf16(&joined, 127);
let w = wide(&s);
// SAFETY: NUL-terminated and alive across the call.
unsafe { qbz_win_tray_set_tooltip(w.as_ptr()) }
}
pub(crate) fn set_playing(playing: bool) {
// SAFETY: a plain bool by value.
unsafe { qbz_win_tray_set_playing(playing) }
}
#[allow(dead_code)] // wired when a Windows quit path needs to remove the icon
pub(crate) fn destroy() {
// SAFETY: no arguments; idempotent on the C side.
unsafe { qbz_win_tray_destroy() }
}
+54
View File
@@ -0,0 +1,54 @@
//! Windows shell helpers backed by `cxx/win_shell.cpp` (the `font_qt.rs`
//! pattern: a thin `extern "C"` seam, one safety note per call).
//!
//! The C++ half compiles on every platform, so this module does too. What is
//! Windows-specific is what the callers do with the handle.
use std::ffi::c_void;
use std::ptr::NonNull;
unsafe extern "C" {
fn qbz_main_window_hwnd() -> *mut c_void;
fn qbz_install_commit_data_handler(cb: extern "C" fn());
fn qbz_install_hittest_filter();
}
/// Answer WM_NCHITTEST for the custom-chrome window the way an ordinary
/// borderless-resizable window would.
///
/// Needed because `Qt::CustomizeWindowHint` -- the only way to stop Qt drawing
/// its own window buttons over the QBZ header -- makes Qt return HTNOWHERE for
/// the whole window, and Windows sends no mouse input to HTNOWHERE. See the
/// long note in `cxx/win_shell.cpp`.
#[cfg(target_os = "windows")]
pub(crate) fn install_hittest_filter() {
// SAFETY: no arguments; installs one process-lifetime filter on the
// QCoreApplication, and is idempotent on the C++ side. Called after
// `QGuiApplication::new`, on the GUI thread.
unsafe { qbz_install_hittest_filter() }
}
/// The top-level window's native handle, or `None` before it is shown.
///
/// GUI THREAD ONLY, and never before `exec()`: `QWindow::winId()` creates the
/// platform window as a side effect, so calling it early both returns a handle
/// for a half-built window and changes the handle the real one ends up with.
#[allow(dead_code)] // consumed by Task 2 (SMTC) and Task 5 (tray)
pub(crate) fn main_window_hwnd() -> Option<NonNull<c_void>> {
// SAFETY: takes no arguments and only reads QGuiApplication state. The
// caller guarantees the GUI thread and a live QGuiApplication.
NonNull::new(unsafe { qbz_main_window_hwnd() })
}
/// Run `cb` when Windows asks whether the session may end (WM_QUERYENDSESSION).
///
/// Qt blocks inside the signal until the handler returns, so `cb` must do its
/// work synchronously -- that is the point of the hook, and the reason a
/// queued connection would be wrong here.
#[allow(dead_code)] // consumed by Task 8 (logoff persistence)
pub(crate) fn install_commit_data_handler(cb: extern "C" fn()) {
// SAFETY: `qApp` exists (this runs after `QGuiApplication::new`), and the
// lambda stored on the C++ side only calls `cb`, which is a `'static` fn
// pointer with no captured state.
unsafe { qbz_install_commit_data_handler(cb) }
}
+24
View File
@@ -130,8 +130,32 @@ impl Backend {
fn try_open_keyring(service_name: &str) -> Result<[u8; MASTER_KEY_LEN], SecretError> {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use keyring::credential::CredentialPersistence;
use keyring::Entry;
// A build without the per-platform keyring feature exports the in-memory
// mock, whose writes SUCCEED and whose contents die with the process: the
// master key would be regenerated every launch and every blob wrapped in
// one run would fail AES-GCM in the next, with the logs claiming the
// keyring works. That is strictly worse than the KDF fallback, which is
// at least stable. Refuse anything that is not disk-backed.
//
// Measured in keyring-3.6.3: every real backend (windows.rs, macos.rs,
// secret_service.rs) inherits the trait default `UntilDelete`
// (credential.rs:171-172); mock.rs:228-229 overrides it to `EntryOnly`.
//
// Reach of this check: it reads the COMPILE-TIME platform builder, which is
// what `Entry::new` uses here. It would not catch a caller that swapped the
// builder at runtime via `keyring::set_default_credential_builder` — nothing
// in this workspace does, and the defect this guards against is a build
// configuration one.
let persistence = keyring::default::default_credential_builder().persistence();
if !matches!(persistence, CredentialPersistence::UntilDelete) {
return Err(SecretError::Keyring(
"keyring backend is not disk-backed (mock?); refusing it".to_string(),
));
}
let entry = Entry::new(service_name, KEYRING_ENTRY_NAME)
.map_err(|e| SecretError::Keyring(format!("Entry::new: {}", e)))?;
+10
View File
@@ -89,8 +89,18 @@ pub(crate) fn machine_id() -> Option<String> {
#[cfg(target_os = "windows")]
{
// HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
use std::os::windows::process::CommandExt;
use std::process::Command;
// The shipped binary is a GUI subsystem image (W4), so it owns no
// console; spawning `reg` without this flag makes Windows allocate one
// and a black box flashes on screen at every startup. CREATE_NO_WINDOW
// suppresses it. The command, its parse and its result are unchanged,
// so no already-derived machine id moves.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let output = Command::new("reg")
.creation_flags(CREATE_NO_WINDOW)
.args([
"query",
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
+11 -6
View File
@@ -73,10 +73,12 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
// so the now-playing bar / queue resolve it from current creds.
// `file://`-prefixing it poisons it into a local-read miss.
let artwork_url = t.artwork_path.as_ref().map(|p| {
if is_plex || p.starts_with("file://") {
if is_plex {
p.clone()
} else {
format!("file://{p}")
// Idempotent, and drive/UNC-aware: format!("file://{p}") turned
// C:/... into a URL whose HOST was the drive letter.
qbz_models::fs_url::file_url(p)
}
});
let sample_rate_khz = if t.sample_rate >= 1000.0 {
@@ -238,7 +240,7 @@ impl LocalSource {
/// `<album>|||<album_artist>` form), and the literal unknown bucket
/// (`library_qt.rs:1053`).
fn is_group_key(id: &str) -> bool {
id.starts_with('/') || id.contains('|') || id == "__unknown_album__"
qbz_models::fs_url::is_local_abs_path(id) || id.contains('|') || id == "__unknown_album__"
}
/// True when `id` is a Plex namespaced row id (`local_plex.rs:37-42`).
@@ -668,8 +670,11 @@ impl Source for LocalSource {
// A `file://` url is ALREADY on disk — reqwest cannot even build a
// request for it ("builder error for url"), so it must never be
// fetched.
if let Some(path) = token.strip_prefix("file://") {
return ArtRef::File(PathBuf::from(path));
if token.starts_with("file://") {
// NOT strip_prefix: on Windows that leaves the `/` before the drive
// (`/C:/Users/...`), which names nothing, and it leaves the three
// percent-escapes in place on every platform.
return ArtRef::File(PathBuf::from(qbz_models::fs_url::local_path(token)));
}
// An offline-download row can keep the CDN url its cover came from.
if token.starts_with("http://") || token.starts_with("https://") {
@@ -678,7 +683,7 @@ impl Source for LocalSource {
cache_key: token.to_string(),
};
}
if token.starts_with('/') {
if qbz_models::fs_url::is_local_abs_path(token) {
return ArtRef::File(PathBuf::from(token));
}
// A bare relative string is not something this source can resolve.
+11 -1
View File
@@ -42,7 +42,6 @@ futures-util = { workspace = true } # remote_stream.r
# new direct deps (versions already in lock where noted)
tiny_http = "0.12" # in lock via qbz-cast
toml = "0.9" # in lock transitively
libc = "0.2" # flock
urlencoding = "2" # OAuth redirect_url encode + callback decode (in lock via qbz)
open = "5" # system-browser launch for `qbzd login` (in lock via qbz)
clap = { version = "4", features = ["derive"] }
@@ -52,5 +51,16 @@ clap_complete = "4"
# The `crossterm` backend is a DEFAULT feature; re-exported as ratatui::crossterm.
ratatui = "0.30"
[target.'cfg(unix)'.dependencies]
libc = "0.2" # flock in src/lock.rs
[target.'cfg(windows)'.dependencies]
# LockFileEx (+ OVERLAPPED, hence Win32_System_IO) for the instance lock.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_IO",
] }
[dev-dependencies]
tempfile = "3"
+9
View File
@@ -235,6 +235,7 @@ fn backend_label(b: Option<qbz_audio::AudioBackendType>) -> Option<String> {
B::Alsa => "alsa",
B::Pulse => "pulse",
B::Jack => "jack",
B::WasapiExclusive => "wasapi",
B::SystemDefault => "system",
}
.to_string()
@@ -276,6 +277,14 @@ fn cached_device_names(state: &super::ApiState) -> Vec<String> {
mod tests {
use super::*;
#[test]
fn windows_exclusive_backend_has_a_stable_status_label() {
assert_eq!(
backend_label(Some(qbz_audio::AudioBackendType::WasapiExclusive)).as_deref(),
Some("wasapi")
);
}
/// A NeedsAuth [`StatusDoc`] built directly (no live runtime), matching the
/// 02 §3.3.3 NeedsAuth fragment — the serde-shape contract these tests pin.
fn needs_auth_doc() -> StatusDoc {
+8 -2
View File
@@ -136,8 +136,11 @@ fn parse_backend(v: &str) -> Result<Option<AudioBackendType>, String> {
"alsa" => Ok(Some(AudioBackendType::Alsa)),
"pulse" | "pulseaudio" => Ok(Some(AudioBackendType::Pulse)),
"jack" => Ok(Some(AudioBackendType::Jack)),
"wasapi" | "wasapi-exclusive" | "wasapi_exclusive" | "wasapiexclusive" => {
Ok(Some(AudioBackendType::WasapiExclusive))
}
other => Err(format!(
"invalid backend '{other}' — expected one of: system, pipewire, alsa, pulse, jack"
"invalid backend '{other}' — expected one of: system, pipewire, alsa, pulse, jack, wasapi"
)),
}
}
@@ -148,6 +151,7 @@ fn render_backend(v: Option<AudioBackendType>) -> String {
Some(AudioBackendType::Alsa) => "alsa".to_string(),
Some(AudioBackendType::Pulse) => "pulse".to_string(),
Some(AudioBackendType::Jack) => "jack".to_string(),
Some(AudioBackendType::WasapiExclusive) => "wasapi".to_string(),
None => "auto".to_string(),
}
}
@@ -1335,10 +1339,12 @@ mod tests {
}
#[test]
fn parse_backend_accepts_the_five_concrete_backends_only() {
fn parse_backend_accepts_the_concrete_backends_only() {
assert_eq!(parse_backend("alsa"), Ok(Some(AudioBackendType::Alsa)));
assert_eq!(parse_backend("PipeWire"), Ok(Some(AudioBackendType::PipeWire)));
assert_eq!(parse_backend("system"), Ok(Some(AudioBackendType::SystemDefault)));
assert_eq!(parse_backend("wasapi"), Ok(Some(AudioBackendType::WasapiExclusive)));
assert_eq!(render_backend(Some(AudioBackendType::WasapiExclusive)), "wasapi");
assert!(parse_backend("auto").is_err(), "Auto is omitted in v1 (03-setup-tui.md §3.2.1)");
assert!(parse_backend("bogus").is_err());
}
+69 -6
View File
@@ -1,9 +1,12 @@
// crates/qbzd/src/lock.rs
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::unix::io::AsRawFd;
use std::path::Path;
/// Offset of the Windows lock byte: 1 GiB, far past the few bytes of pid text.
#[cfg(windows)]
const LOCK_BYTE_OFFSET: u32 = 1 << 30;
#[derive(Debug)]
pub struct InstanceLock {
_file: std::fs::File,
@@ -28,11 +31,71 @@ impl InstanceLock {
.write(true)
.open(&path)
.map_err(|e| LockError::Io(e.to_string()))?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let mut pid = String::new();
let _ = file.read_to_string(&mut pid);
return Err(LockError::AlreadyRunning(pid.trim().parse().ok()));
// Ok(true) = we hold it. Ok(false) = another instance does.
// Err = the lock could not even be ATTEMPTED, which is not the same
// thing and must not be reported as AlreadyRunning.
#[cfg(unix)]
let locked: Result<bool, std::io::Error> = {
use std::os::unix::io::AsRawFd;
// Deliberately NOT narrowed to EWOULDBLOCK: this is byte-identical
// to the pre-port behaviour, and the Windows port does not move
// Linux semantics.
Ok(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) == 0 })
};
#[cfg(windows)]
let locked: Result<bool, std::io::Error> = {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
use windows_sys::Win32::Storage::FileSystem::{
LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
};
let mut ov: windows_sys::Win32::System::IO::OVERLAPPED =
unsafe { std::mem::zeroed() };
// A LOCK BYTE past any real content, not the whole file. Windows
// byte-range locks are MANDATORY: locking from offset 0 would make
// the `read_to_string` below fail for the losing instance, so
// AlreadyRunning would always carry None instead of the pid that is
// the whole point of the error. Reserving a region past EOF is the
// same trick SQLite's Windows VFS uses. flock's advisory semantics
// need no such care.
ov.Anonymous.Anonymous.Offset = LOCK_BYTE_OFFSET;
// SAFETY: `file` is open so its handle is valid for the call; `ov`
// is fully initialised and outlives the call. The lock is released
// when `file` drops and the handle closes, like flock.
let ok = unsafe {
LockFileEx(
file.as_raw_handle() as _,
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
0,
1,
0,
&mut ov,
)
};
if ok != 0 {
Ok(true)
} else {
let err = std::io::Error::last_os_error();
// Only ERROR_LOCK_VIOLATION means "another instance holds it".
// A filesystem that does not implement byte-range locks answers
// ERROR_INVALID_FUNCTION instead (network shares, \wsl$), and
// calling that "another daemon is running" would be a lie that
// blocks startup for good.
if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
Ok(false)
} else {
Err(err)
}
}
};
match locked {
Ok(true) => {}
Ok(false) => {
let mut pid = String::new();
let _ = file.read_to_string(&mut pid);
return Err(LockError::AlreadyRunning(pid.trim().parse().ok()));
}
Err(e) => return Err(LockError::Io(e.to_string())),
}
file.set_len(0)
.and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ()))
+11 -5
View File
@@ -81,11 +81,17 @@ pub fn spawn(
// integration never keeps the runtime alive.
let weak: Weak<AppRuntime<DaemonAdapter>> = Arc::downgrade(runtime);
let cb_handle = handle.clone();
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(move |ev| {
if let Some(rt) = weak.upgrade() {
handle_media_event(&rt, &roots, &cb_handle, ev);
}
})?);
// Headless: the daemon owns no window, so there is no HWND to give.
// On Linux, which is the only place qbzd ships, MPRIS keys off the D-Bus
// name and ignores this entirely.
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(
move |ev| {
if let Some(rt) = weak.upgrade() {
handle_media_event(&rt, &roots, &cb_handle, ev);
}
},
qbz_media_controls::NativeWindow::default(),
)?);
log::info!("[mpris] publishing org.mpris.MediaPlayer2 (desktop media controls + media keys)");
// OUTBOUND: seed once from live state, then follow the bus.
+7 -11
View File
@@ -106,18 +106,14 @@ pub fn resolve_qconnect_device_type() -> i32 {
}
pub fn resolve_system_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME") {
if !h.trim().is_empty() {
return h.trim().to_string();
}
// One implementation, in qbz-app: this copy read HOSTNAME then
// /etc/hostname, neither of which Windows has, so every Windows install
// announced itself with the same name. The "Desktop" last resort is kept
// here rather than pushed down: qbzd's differs, and both are user-visible.
match qbz_app::settings::bundle::hostname().as_str() {
"unknown" => "Desktop".to_string(),
h => h.to_string(),
}
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let trimmed = h.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
}
"Desktop".to_string()
}
/// Returns "QBZ ({hostname})" as the default device name (OD6 — daemon
+6 -1
View File
@@ -213,7 +213,10 @@ fn device_is_bit_perfect(backend: AudioBackendType, d: &AudioDevice) -> bool {
alsa_section(&d.id, d.is_default, label) == AlsaSection::BitPerfect
}
AudioBackendType::PipeWire => d.is_hardware,
AudioBackendType::Pulse | AudioBackendType::SystemDefault | AudioBackendType::Jack => false,
AudioBackendType::Pulse
| AudioBackendType::SystemDefault
| AudioBackendType::Jack
| AudioBackendType::WasapiExclusive => false,
}
}
@@ -815,6 +818,7 @@ pub fn backend_label(b: AudioBackendType) -> String {
AudioBackendType::Pulse => "PulseAudio".to_string(),
AudioBackendType::SystemDefault => "System default".to_string(),
AudioBackendType::Jack => "JACK".to_string(),
AudioBackendType::WasapiExclusive => "WASAPI Exclusive".to_string(),
}
}
@@ -826,6 +830,7 @@ fn backend_value(b: AudioBackendType) -> &'static str {
AudioBackendType::Alsa => "alsa",
AudioBackendType::Pulse => "pulse",
AudioBackendType::Jack => "jack",
AudioBackendType::WasapiExclusive => "wasapi",
}
}