diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 72915cf40..020f88a14 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2921,6 +2921,7 @@ dependencies = [ "symphonia", "thiserror 2.0.18", "tokio", + "zbus", ] [[package]] @@ -4387,6 +4388,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.6.3", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -5664,6 +5666,7 @@ dependencies = [ "serde_repr", "sha1", "static_assertions", + "tokio", "tracing", "uds_windows", "windows-sys 0.52.0", diff --git a/crates/qbz-audio/Cargo.toml b/crates/qbz-audio/Cargo.toml index e6d58d893..04b8530ca 100644 --- a/crates/qbz-audio/Cargo.toml +++ b/crates/qbz-audio/Cargo.toml @@ -41,6 +41,10 @@ dirs = "6" alsa = "0.10" # alsa-sys: direct FFI for snd_lib_error_set_handler (custom error routing) alsa-sys = "0.3" +# zbus for org.freedesktop.ReserveDevice1 D-Bus client (device reservation). +# Pinned to v4 to share the transitive copy already pulled in by +# secret-service/souvlaki and avoid duplicate zbus compiles. +zbus = { version = "4", default-features = false, features = ["tokio"] } [target.'cfg(target_os = "macos")'.dependencies] # CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode diff --git a/crates/qbz-audio/src/device_reservation.rs b/crates/qbz-audio/src/device_reservation.rs new file mode 100644 index 000000000..33b5f8250 --- /dev/null +++ b/crates/qbz-audio/src/device_reservation.rs @@ -0,0 +1,18 @@ +//! D-Bus device reservation via org.freedesktop.ReserveDevice1. +//! +//! Acquires the bus name org.freedesktop.ReserveDevice1.Audio for the +//! card index N, signalling to PulseAudio/PipeWire/WirePlumber that another +//! application owns the device exclusively. Released on Drop. +//! +//! See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md` +//! for the full protocol specification and lifetime model. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(not(target_os = "linux"))] +mod stub; +#[cfg(not(target_os = "linux"))] +pub use stub::*; diff --git a/crates/qbz-audio/src/device_reservation/linux.rs b/crates/qbz-audio/src/device_reservation/linux.rs new file mode 100644 index 000000000..2defbbd07 --- /dev/null +++ b/crates/qbz-audio/src/device_reservation/linux.rs @@ -0,0 +1,193 @@ +//! Linux implementation of `DeviceReservation`. +//! +//! Task 1 ships the public surface, parser helpers, and unit tests for them. +//! The actual zbus client is wired in Task 2 — for now `acquire()` validates +//! the device string and returns a degraded guard so call sites can be +//! plumbed without breaking the build. + +use std::fmt; + +#[derive(Debug)] +pub struct DeviceReservation { + state: ReservationState, +} + +#[derive(Debug)] +#[allow(dead_code)] // `Active` is constructed in Task 2 once the zbus client lands. +enum ReservationState { + Active { + // Filled in Task 2 with the zbus connection + bus name we own. + _placeholder: (), + }, + Degraded, +} + +impl DeviceReservation { + /// Acquire a reservation for the given ALSA hw: device string. + /// + /// Returns `Ok(guard)` on success. On D-Bus unavailability or any other + /// non-fatal failure path, the implementation in Task 2 will return a + /// degraded guard so playback proceeds without reservation. For now the + /// device string is validated and a degraded guard is always returned. + pub fn acquire(hw_device: &str, _app_device_name: &str) -> Result { + // Validate the device string. Real D-Bus acquisition lands in Task 2. + let _card = parse_card_index(hw_device)?; + Ok(Self { + state: ReservationState::Degraded, + }) + } + + /// Whether this guard currently holds an active D-Bus reservation. + pub fn is_active(&self) -> bool { + matches!(self.state, ReservationState::Active { .. }) + } +} + +impl Drop for DeviceReservation { + fn drop(&mut self) { + // Real release lands in Task 2. + } +} + +#[derive(Debug)] +pub enum ReservationError { + InvalidDevice(String), + HigherPriorityHolder { + holder_name: String, + holder_priority: i32, + }, + DbusError(String), +} + +impl fmt::Display for ReservationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidDevice(s) => write!(f, "invalid ALSA device string: {}", s), + Self::HigherPriorityHolder { + holder_name, + holder_priority, + } => write!( + f, + "device reserved by '{}' at priority {}", + holder_name, holder_priority + ), + Self::DbusError(s) => write!(f, "D-Bus error: {}", s), + } + } +} + +impl std::error::Error for ReservationError {} + +/// Parse an ALSA device string and return the kernel card index. +/// +/// Accepts: `"hw:0"`, `"hw:0,0"`, `"hw:1,0"`, `"plughw:1,0"`, +/// `"hw:CARD=DacMagic"`, `"hw:CARD=DacMagic,DEV=0"`. +pub(crate) fn parse_card_index(hw_device: &str) -> Result { + let trimmed = hw_device.trim(); + let after_prefix = trimmed + .strip_prefix("hw:") + .or_else(|| trimmed.strip_prefix("plughw:")) + .ok_or_else(|| ReservationError::InvalidDevice(hw_device.to_string()))?; + + let card_part = after_prefix.split(',').next().unwrap_or(""); + let card_part = card_part.trim(); + + if card_part.is_empty() { + return Err(ReservationError::InvalidDevice(hw_device.to_string())); + } + + if let Some(name) = card_part.strip_prefix("CARD=") { + resolve_card_index_by_name(name) + } else { + card_part + .parse::() + .map_err(|_| ReservationError::InvalidDevice(hw_device.to_string())) + } +} + +/// Resolve a symbolic ALSA card name (e.g., `"DacMagic"`) to its kernel index +/// by iterating over `alsa::card::Iter`. +fn resolve_card_index_by_name(name: &str) -> Result { + for card in alsa::card::Iter::new() { + let card = card.map_err(|e| ReservationError::DbusError(e.to_string()))?; + let id = card.get_name().unwrap_or_default(); + if id == name { + return Ok(card.get_index() as u32); + } + } + Err(ReservationError::InvalidDevice(format!( + "ALSA card '{}' not found", + name + ))) +} + +/// Format the well-known D-Bus bus name for a given ALSA card index. +#[allow(dead_code)] // Used by the zbus client in Task 2. +pub(crate) fn bus_name_for_card(card_index: u32) -> String { + format!("org.freedesktop.ReserveDevice1.Audio{}", card_index) +} + +/// Format the D-Bus object path for a given ALSA card index. +#[allow(dead_code)] // Used by the zbus client in Task 2. +pub(crate) fn object_path_for_card(card_index: u32) -> String { + format!("/org/freedesktop/ReserveDevice1/Audio{}", card_index) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_card_index_basic() { + assert_eq!(parse_card_index("hw:0").unwrap(), 0); + assert_eq!(parse_card_index("hw:1,0").unwrap(), 1); + assert_eq!(parse_card_index("plughw:2,0").unwrap(), 2); + assert_eq!(parse_card_index("hw:99,3").unwrap(), 99); + } + + #[test] + fn parse_card_index_rejects_garbage() { + assert!(matches!( + parse_card_index("default"), + Err(ReservationError::InvalidDevice(_)) + )); + assert!(matches!( + parse_card_index("hw:"), + Err(ReservationError::InvalidDevice(_)) + )); + assert!(matches!( + parse_card_index(""), + Err(ReservationError::InvalidDevice(_)) + )); + } + + #[test] + fn bus_name_format() { + assert_eq!( + bus_name_for_card(0), + "org.freedesktop.ReserveDevice1.Audio0" + ); + assert_eq!( + bus_name_for_card(7), + "org.freedesktop.ReserveDevice1.Audio7" + ); + assert_eq!( + bus_name_for_card(99), + "org.freedesktop.ReserveDevice1.Audio99" + ); + } + + #[test] + fn object_path_format() { + assert_eq!( + object_path_for_card(0), + "/org/freedesktop/ReserveDevice1/Audio0" + ); + } + + #[test] + fn degraded_guard_reports_inactive() { + let g = DeviceReservation::acquire("hw:0,0", "test").unwrap(); + assert!(!g.is_active()); + } +} diff --git a/crates/qbz-audio/src/device_reservation/stub.rs b/crates/qbz-audio/src/device_reservation/stub.rs new file mode 100644 index 000000000..811d1947b --- /dev/null +++ b/crates/qbz-audio/src/device_reservation/stub.rs @@ -0,0 +1,37 @@ +//! Non-Linux stub for `DeviceReservation`. +//! +//! The org.freedesktop.ReserveDevice1 protocol is a Linux/D-Bus convention. +//! On macOS and Windows, `acquire()` always succeeds with a degraded guard +//! so that call sites stay portable; `is_active()` always returns `false`. + +#[derive(Debug)] +pub struct DeviceReservation; + +impl DeviceReservation { + /// Acquire a reservation for the given ALSA hw: device string. + /// + /// On non-Linux platforms this always returns a degraded guard (no-op). + pub fn acquire(_hw_device: &str, _app_device_name: &str) -> Result { + Ok(Self) + } + + /// Whether this guard holds an active D-Bus reservation. + /// + /// Always `false` on non-Linux platforms. + pub fn is_active(&self) -> bool { + false + } +} + +#[derive(Debug)] +pub enum ReservationError { + Unsupported, +} + +impl std::fmt::Display for ReservationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "device reservation is not supported on this platform") + } +} + +impl std::error::Error for ReservationError {} diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index 7b3a35099..ddd18baee 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -39,6 +39,7 @@ pub mod alsa_direct; pub mod coreaudio_direct; pub mod analysis; pub mod analyzer_tap; +pub mod device_reservation; pub mod diagnostic; pub mod dynamic_amplify; pub mod loudness; @@ -61,6 +62,7 @@ pub use backend::{ AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendManager, BackendResult, BitPerfectMode, }; +pub use device_reservation::{DeviceReservation, ReservationError}; pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource}; pub use dynamic_amplify::DynamicAmplify; pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData}; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 380166476..0429e4212 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4888,6 +4888,7 @@ dependencies = [ "symphonia", "thiserror 2.0.18", "tokio", + "zbus 4.4.0", ] [[package]] @@ -9293,6 +9294,7 @@ dependencies = [ "serde_repr", "sha1", "static_assertions", + "tokio", "tracing", "uds_windows", "windows-sys 0.52.0",