93 files changed
+6794
-261
No files matched your search
Generated
+49
-1
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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"] }
|
||||
@@ -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 "Я понимаю"
|
||||
@@ -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"
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod fs_url;
|
||||
pub mod lenient;
|
||||
pub mod mixtape;
|
||||
pub mod playback;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) ---------
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)))?;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(|_| ()))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user