diff --git a/src-tauri/src/commands_v2/audio.rs b/src-tauri/src/commands_v2/audio.rs index 407643947..b8fb2d3f8 100644 --- a/src-tauri/src/commands_v2/audio.rs +++ b/src-tauri/src/commands_v2/audio.rs @@ -138,6 +138,7 @@ pub async fn v2_set_audio_output_device( device: Option, state: State<'_, AudioSettingsState>, bridge: State<'_, CoreBridgeState>, + app_state: State<'_, crate::AppState>, ) -> Result<(), RuntimeError> { let normalized_device = device .as_ref() @@ -147,7 +148,7 @@ pub async fn v2_set_audio_output_device( device, normalized_device ); - { + let lifetime_b_enabled = { let guard = state .store .lock() @@ -158,8 +159,21 @@ pub async fn v2_set_audio_output_device( store .set_output_device(normalized_device.as_deref()) .map_err(RuntimeError::Internal)?; - } + store + .get_settings() + .map_err(RuntimeError::Internal)? + .reserve_dac_while_running + }; sync_audio_settings_to_player(&state, &bridge).await; + + // Lifetime B: when the user changes DAC, drop the old reservation + // and reacquire on the new card. Pass `None` if the new device isn't + // card-specific (apply_dac_reservation will release without + // reacquiring) or if the toggle is off. + if lifetime_b_enabled { + app_state + .apply_dac_reservation(normalized_device.as_deref(), normalized_device.as_deref()); + } Ok(()) } @@ -663,3 +677,137 @@ pub async fn v2_set_audio_alsa_hardware_volume( sync_audio_settings_to_player(&state, &bridge).await; Ok(()) } + +// ==================== DAC Reservation (Lifetime B) ==================== +// +// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md` +// for the protocol-level design. Lifetime B = a long-lived +// `DeviceReservation` guard held in `AppState` for the QBZ process, +// gated by the `reserve_dac_while_running` audio setting. + +/// Status payload returned by `v2_get_dac_reservation_status`. +/// +/// `Inactive` covers the toggle-off and non-card-device cases. `Active` +/// means we currently hold the bus name. `Contested` means the toggle is +/// on but acquisition failed because another app holds the device at +/// equal-or-higher priority. `Unavailable` means the runtime environment +/// (no D-Bus session, sandbox without bus access) prevents any reservation +/// from working — surfaces as a degraded guard. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DacReservationStatus { + Inactive, + Active, + Contested { + holder: String, + holder_priority: i32, + }, + Unavailable { + reason: String, + }, +} + +/// Toggle the per-process DAC reservation (Lifetime B). +/// +/// Persists the flag to `audio_settings` and applies it immediately: +/// `enabled=true` acquires a reservation for the current output device +/// (when it targets a single ALSA card); `enabled=false` releases the +/// guard. Idempotent — re-calling with the same value drops and +/// reacquires safely. +#[tauri::command] +pub async fn v2_set_reserve_dac_while_running( + enabled: bool, + state: State<'_, AudioSettingsState>, + app_state: State<'_, crate::AppState>, +) -> Result<(), RuntimeError> { + log::info!("[V2] set_reserve_dac_while_running: {}", enabled); + + // 1. Persist the flag in the audio_settings DB. + let output_device = { + let guard = state + .store + .lock() + .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; + let store = guard + .as_ref() + .ok_or(RuntimeError::UserSessionNotActivated)?; + store + .set_reserve_dac_while_running(enabled) + .map_err(RuntimeError::Internal)?; + // Re-read the current device while we hold the lock — avoids a + // TOCTOU window where the user could change DAC between this + // setter and our apply call. + store + .get_settings() + .map_err(RuntimeError::Internal)? + .output_device + }; + + // 2. Apply: take or release the guard. None on disable; current + // output device on enable. + let device_for_apply = if enabled { output_device } else { None }; + app_state.apply_dac_reservation(device_for_apply.as_deref(), device_for_apply.as_deref()); + Ok(()) +} + +/// Snapshot the current Lifetime-B reservation state for the UI. +/// +/// Reads both the persisted flag and the live guard so the UI can +/// distinguish between "user hasn't enabled it" (`Inactive`), "we own +/// it" (`Active`), "user enabled it but a higher-priority app holds the +/// DAC" (`Contested`), and "D-Bus is unavailable in this environment" +/// (`Unavailable`). +#[tauri::command] +pub fn v2_get_dac_reservation_status( + state: State<'_, AudioSettingsState>, + app_state: State<'_, crate::AppState>, +) -> Result { + let snapshot = { + let guard = state + .store + .lock() + .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; + let store = guard + .as_ref() + .ok_or(RuntimeError::UserSessionNotActivated)?; + store.get_settings().map_err(RuntimeError::Internal)? + }; + + if !snapshot.reserve_dac_while_running { + return Ok(DacReservationStatus::Inactive); + } + match snapshot.output_device.as_deref() { + None => return Ok(DacReservationStatus::Inactive), + Some(d) if !is_card_specific_device(d) => { + return Ok(DacReservationStatus::Inactive); + } + _ => {} + } + + let guard = app_state + .dac_reservation + .lock() + .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; + Ok(match guard.as_ref() { + Some(r) if r.is_active() => DacReservationStatus::Active, + Some(_) => DacReservationStatus::Unavailable { + reason: "D-Bus session bus unreachable or device not card-specific".to_string(), + }, + None => DacReservationStatus::Contested { + holder: "unknown".to_string(), + holder_priority: 0, + }, + }) +} + +/// Local mirror of `crate::is_card_specific_device` so the status +/// command's gating matches `apply_dac_reservation`'s gating without +/// crossing a module boundary just for one private helper. +fn is_card_specific_device(s: &str) -> bool { + s.starts_with("hw:") + || s.starts_with("plughw:") + || s.starts_with("front:") + || s.starts_with("surround") + || s.starts_with("iec958:") + || s.starts_with("hdmi:") +} diff --git a/src-tauri/src/config/audio_settings.rs b/src-tauri/src/config/audio_settings.rs index 969a5b7e6..0eb6e404f 100644 --- a/src-tauri/src/config/audio_settings.rs +++ b/src-tauri/src/config/audio_settings.rs @@ -199,6 +199,10 @@ impl AudioSettingsStore { "ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0", [], ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0", + [], + ); Ok(Self { conn }) } @@ -217,7 +221,7 @@ impl AudioSettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1", + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string @@ -260,9 +264,10 @@ impl AudioSettingsStore { .unwrap_or_else(|| "ask".to_string()), skip_sink_switch: row.get::<_, Option>(19)?.unwrap_or(0) != 0, allow_quality_fallback: row.get::<_, Option>(20)?.unwrap_or(0) != 0, - // Persisted in Task 5 (Lifetime B). Default to false until the - // DB column + setter exist; serde_default keeps JSON round-trips clean. - reserve_dac_while_running: false, + reserve_dac_while_running: row + .get::<_, Option>(21)? + .unwrap_or(0) + != 0, }) }, ) @@ -514,6 +519,21 @@ impl AudioSettingsStore { Ok(()) } + /// Persist the `reserve_dac_while_running` flag (Lifetime B from the + /// ALSA exclusive-hardening design spec). Toggling this only updates + /// the DB row; applying the change to the live `DeviceReservation` + /// guard is the caller's responsibility — see + /// `AppState::apply_dac_reservation`. + pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?; + Ok(()) + } + pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> { self.conn .execute( @@ -612,7 +632,8 @@ impl AudioSettingsStore { pw_force_bitperfect = ?17, sync_audio_on_startup = ?18, skip_sink_switch = ?19, - allow_quality_fallback = ?20 + allow_quality_fallback = ?20, + reserve_dac_while_running = ?21 WHERE id = 1", params![ defaults.output_device, @@ -635,6 +656,7 @@ impl AudioSettingsStore { defaults.sync_audio_on_startup as i64, defaults.skip_sink_switch as i64, defaults.allow_quality_fallback as i64, + defaults.reserve_dac_while_running as i64, ], ) .map_err(|e| format!("Failed to reset audio settings: {}", e))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0af2d4e4f..86a822dbe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,7 +77,7 @@ pub mod visualizer; pub mod qbzd_discovery; use rustls::crypto::{aws_lc_rs, CryptoProvider}; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use tauri::{Emitter, Manager}; use tokio::sync::{Mutex, RwLock}; @@ -87,6 +87,7 @@ use lastfm::LastFmClient; use media_controls::{MediaControlsManager, TrackInfo}; use playback_context::ContextManager; use player::Player; +use qbz_audio::DeviceReservation; use queue::QueueManager; use share::SongLinkClient; use visualizer::Visualizer; @@ -104,6 +105,14 @@ pub struct AppState { pub visualizer: Visualizer, #[cfg(target_os = "linux")] pub idle_inhibitor: idle_inhibit::IdleInhibitor, + /// Long-lived per-process DAC reservation (Lifetime B from the + /// ALSA exclusive-hardening design spec). Held while the + /// `reserve_dac_while_running` audio setting is true and the + /// selected output device targets a single ALSA card. + /// Acquired at startup or via `v2_set_reserve_dac_while_running`, + /// dropped on toggle-off, DAC change to a non-card device, or + /// `AppState` drop. + pub dac_reservation: StdMutex>, } impl AppState { @@ -166,6 +175,83 @@ impl AppState { visualizer, #[cfg(target_os = "linux")] idle_inhibitor: idle_inhibit::IdleInhibitor::new(), + dac_reservation: StdMutex::new(None), + } + } + + /// Apply the `reserve_dac_while_running` setting (Lifetime B from the + /// ALSA exclusive-hardening design spec). + /// + /// Idempotent: drops any currently-held guard, then re-acquires for the + /// new device when both arguments make sense (Some + targets a single + /// ALSA card via a recognized plugin prefix). Passing `None` for + /// `hw_device`, or a device string the parser can't tie to one card + /// (`default`, `pulse`, etc.), releases the existing guard without + /// reacquiring. + /// + /// `app_device_name` is the user-facing DAC label that will be advertised + /// over D-Bus once QBZ publishes the `ReserveDevice1` interface as a + /// server (see the design spec's "Note on `ApplicationName`" — that path + /// is deferred). Today the value is captured for logging only; pass the + /// same `hw_device` string if no friendlier name is at hand. + /// + /// On acquisition failure (higher-priority holder, D-Bus error) the + /// guard is left as `None` and a warning is logged. Status is surfaced + /// to the UI via `v2_get_dac_reservation_status`. + pub fn apply_dac_reservation( + &self, + hw_device: Option<&str>, + app_device_name: Option<&str>, + ) { + let mut guard = match self.dac_reservation.lock() { + Ok(g) => g, + Err(poisoned) => { + log::warn!( + "[reservation] dac_reservation mutex poisoned, recovering: {}", + poisoned + ); + poisoned.into_inner() + } + }; + // Drop any existing guard FIRST so the bus name is released before + // we attempt to reacquire (idempotent re-apply). This matters when + // the user changes DACs: the old card's bus name must release + // before the new card's bus name is requested, otherwise PipeWire + // may not have moved off the old card yet. + *guard = None; + + let Some(hw) = hw_device else { + return; + }; + if !is_card_specific_device(hw) { + return; + } + + let app_name = app_device_name.unwrap_or(hw); + match DeviceReservation::acquire(hw, app_name) { + Ok(r) => { + if r.is_active() { + log::info!( + "[reservation] persistent DAC reservation acquired for {}", + hw + ); + } else { + log::info!( + "[reservation] persistent DAC reservation degraded for {} (D-Bus unavailable or non-card device)", + hw + ); + } + *guard = Some(r); + } + Err(e) => { + log::warn!( + "[reservation] persistent reservation failed for {}: {}", + hw, + e + ); + // Leave guard as None; the v2_get_dac_reservation_status + // command surfaces the contended state to the UI. + } } } } @@ -176,6 +262,24 @@ impl Default for AppState { } } +/// Whether an ALSA device id targets a single card (so its `ReserveDevice1` +/// bus name has well-defined per-card semantics). Used by +/// `AppState::apply_dac_reservation` and `v2_get_dac_reservation_status` to +/// gate Lifetime-B reservation on devices the parser can map to one card. +/// +/// Conservative allow-list: prefixes whose ALSA configurations all expand +/// to a single card. `default`, `pulse`, `null`, etc. are intentionally +/// excluded — they don't map to a single piece of hardware and a +/// per-card reservation has no defined meaning. +fn is_card_specific_device(s: &str) -> bool { + s.starts_with("hw:") + || s.starts_with("plughw:") + || s.starts_with("front:") + || s.starts_with("surround") + || s.starts_with("iec958:") + || s.starts_with("hdmi:") +} + /// Update MPRIS metadata when track changes pub fn update_media_controls_metadata( media_controls: &MediaControlsManager, @@ -808,11 +912,34 @@ pub fn run(qconnect_cli_override: Option) { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()); + // Capture the bits we need to apply Lifetime-B DAC reservation before + // moving `audio_settings` into AppState. Reservation is opt-in via + // `reserve_dac_while_running` and only meaningful when the saved + // output device targets a single ALSA card (hw:/plughw:/front:/...). + let lifetime_b_enabled = audio_settings.reserve_dac_while_running; + let lifetime_b_device = if lifetime_b_enabled { + audio_settings.output_device.clone() + } else { + None + }; + + let app_state = AppState::with_device_and_settings(saved_device, audio_settings); + if lifetime_b_enabled { + if let Some(ref dev) = lifetime_b_device { + log::info!( + "[reservation] applying persisted Lifetime-B reservation at startup for {}", + dev + ); + app_state.apply_dac_reservation(Some(dev), Some(dev)); + } else { + log::info!( + "[reservation] reserve_dac_while_running is true but no output device is set; skipping startup reservation" + ); + } + } + builder - .manage(AppState::with_device_and_settings( - saved_device, - audio_settings, - )) + .manage(app_state) .manage(core_bridge::CoreBridgeState::new()) .manage(commands_v2::OAuthCancelState::new()) .manage(qbzd_discovery::QbzdDiscoveryState::new()) @@ -1806,6 +1933,8 @@ pub fn run(qconnect_cli_override: Option) { commands_v2::v2_set_audio_stream_first_track, commands_v2::v2_set_audio_stream_buffer_seconds, commands_v2::v2_set_audio_alsa_hardware_volume, + commands_v2::v2_set_reserve_dac_while_running, + commands_v2::v2_get_dac_reservation_status, commands_v2::v2_listenbrainz_get_status, commands_v2::v2_listenbrainz_is_enabled, commands_v2::v2_listenbrainz_set_enabled,