feat(reservation): per-process DAC reservation + Tauri commands
Lifetime B from the design spec: a long-lived DeviceReservation guard held in AppState, gated by the reserve_dac_while_running setting. Acquired at startup if the persisted flag is true and the selected device targets a single ALSA card; reapplied when the user picks a different DAC; released on toggle-off, on a switch to a non-card device, or AppState drop. DB persistence: ALTER TABLE migration adds the column, SELECT and reset_all are extended, set_reserve_dac_while_running setter parallels the established pattern for boolean audio settings. Tauri commands (V2 cleanroom): - v2_set_reserve_dac_while_running(enabled) - persist + apply - v2_get_dac_reservation_status() -> DacReservationStatus Status enum mirrors the four cases in the spec: Inactive (toggle off, or non-card-specific device), Active (we hold the bus name), Contested (another app holds the device at higher priority - guard is None but the toggle is on), Unavailable (D-Bus unreachable, degraded guard). v2_set_audio_output_device gains an AppState dependency so DAC changes trigger a reservation handover when the toggle is on. UI surfaces (Settings mirror + HiFi Wizard) land in Tasks 7-8. cc
This commit is contained in:
3 files changed
+311
-12
No files matched your search
@@ -138,6 +138,7 @@ pub async fn v2_set_audio_output_device(
|
||||
device: Option<String>,
|
||||
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<DacReservationStatus, RuntimeError> {
|
||||
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:")
|
||||
}
|
||||
@@ -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<AudioSettings, String> {
|
||||
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<i64>>(19)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(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<i64>>(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))?;
|
||||
|
||||
+134
-5
@@ -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<Option<DeviceReservation>>,
|
||||
}
|
||||
|
||||
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<bool>) {
|
||||
.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<bool>) {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user