feat(qbzd): hifi wizard section with copyable config blocks in setup TUI
cc
This commit is contained in:
7 files changed
+2292
-15
No files matched your search
+192
-7
@@ -18,7 +18,7 @@ use qbz_app::settings::bundle::{self, Bundle, ExportOptions, ExportSource, Impor
|
||||
use qbz_app::settings::daemon_prefs;
|
||||
use qbz_app::settings::playback::PlaybackPreferencesStore;
|
||||
use qbz_audio::settings::{AudioSettings, AudioSettingsStore};
|
||||
use qbz_audio::{AudioBackendType, AudioDevice, BackendManager};
|
||||
use qbz_audio::{AudioBackendType, AudioDevice, BackendManager, NegotiatedRate};
|
||||
|
||||
use crate::cli::client::{ApiClient, CliError};
|
||||
use crate::config::QbzdConfig;
|
||||
@@ -32,9 +32,11 @@ use super::screens::bundle::{BundleState, PendingImport};
|
||||
use super::screens::network::{self as network_screen, NetworkState};
|
||||
use super::screens::playback::PlaybackState;
|
||||
use super::screens::qconnect::QConnectState;
|
||||
use super::screens::wizard::WizardState;
|
||||
use super::strings as s;
|
||||
use super::theme;
|
||||
use super::widgets;
|
||||
use super::wizard_core::{self, DacCandidateData, DacConfigData};
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
@@ -53,16 +55,20 @@ pub enum Screen {
|
||||
QConnect,
|
||||
Network,
|
||||
Bundle,
|
||||
Wizard,
|
||||
}
|
||||
|
||||
/// D7 hard cap — a seventh screen needs an owner decision, not a PR.
|
||||
pub const SCREENS: [Screen; 6] = [
|
||||
/// Seven sections. The original D7 six-screen cap was broken deliberately for
|
||||
/// FB4's HiFi Wizard (owner-sanctioned); a further screen still needs an owner
|
||||
/// decision, not a PR.
|
||||
pub const SCREENS: [Screen; 7] = [
|
||||
Screen::Account,
|
||||
Screen::Audio,
|
||||
Screen::Playback,
|
||||
Screen::QConnect,
|
||||
Screen::Network,
|
||||
Screen::Bundle,
|
||||
Screen::Wizard,
|
||||
];
|
||||
|
||||
/// Sidebar width (columns, incl. border). 14 keeps the content frame at 64 inner
|
||||
@@ -113,6 +119,7 @@ fn section_title(screen: Screen) -> &'static str {
|
||||
Screen::QConnect => s::QCONNECT_TITLE,
|
||||
Screen::Network => s::NETWORK_TITLE,
|
||||
Screen::Bundle => s::BUNDLE_TITLE,
|
||||
Screen::Wizard => s::WIZARD_TITLE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +169,10 @@ enum NavIntent {
|
||||
/// is open in the content (it owns every key); `uses_horizontal` = the focused
|
||||
/// content field consumes ←/→ (Audio's Buffer slider), so ← must NOT drop focus.
|
||||
fn classify_key(focus: Focus, code: KeyCode, editing: bool, uses_horizontal: bool) -> NavIntent {
|
||||
// Number keys 1-6 jump from ANY focus — but only when no field editor is
|
||||
// Number keys 1-7 jump from ANY focus — but only when no field editor is
|
||||
// capturing input (a port/token/name field must receive its digits).
|
||||
if !editing {
|
||||
if let KeyCode::Char(c @ '1'..='6') = code {
|
||||
if let KeyCode::Char(c @ '1'..='7') = code {
|
||||
return NavIntent::JumpSection(c as usize - '1' as usize);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +218,19 @@ pub enum ScreenAction {
|
||||
ImportPlan(String),
|
||||
ImportApply,
|
||||
Export { dest: String, include_auth: bool },
|
||||
// ---- HiFi Wizard (FB4) worker requests ----
|
||||
/// Run the heavy audio-stack health probe (Check step).
|
||||
WizardProbeHealth,
|
||||
/// Enumerate DAC candidates via pw-dump (Select-DACs step).
|
||||
WizardDetect,
|
||||
/// Generate the per-DAC config blocks (Review step).
|
||||
WizardGenConfigs(Vec<(String, String)>),
|
||||
/// Start the daemon's own playback of the current queue, then read back.
|
||||
WizardTestStart,
|
||||
/// Re-read the requested-vs-negotiated rate without (re)starting playback.
|
||||
WizardTestPoll,
|
||||
/// Esc mid-wizard — open the confirm-abandon modal.
|
||||
WizardAbandon,
|
||||
}
|
||||
|
||||
/// Read-only context passed to every screen's `draw` (the live status body for
|
||||
@@ -236,6 +256,15 @@ pub enum Msg {
|
||||
ImportPlanned(Result<Box<PendingImport>, String>),
|
||||
ImportApplied { lines: Vec<String>, status: Option<Value>, reachable: bool },
|
||||
Exported(Result<Vec<String>, String>),
|
||||
// ---- HiFi Wizard (FB4) worker results ----
|
||||
WizardHealth(qbz_audio::AudioStackHealth),
|
||||
WizardDacs(Vec<DacCandidateData>),
|
||||
WizardConfigs(Vec<DacConfigData>),
|
||||
WizardTest {
|
||||
requested: Option<(u32, u32)>,
|
||||
negotiated: Option<NegotiatedRate>,
|
||||
note: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================ active screen ============================
|
||||
@@ -247,6 +276,7 @@ enum Active {
|
||||
QConnect(QConnectState),
|
||||
Network(NetworkState),
|
||||
Bundle(BundleState),
|
||||
Wizard(WizardState),
|
||||
}
|
||||
|
||||
enum Overlay {
|
||||
@@ -254,6 +284,8 @@ enum Overlay {
|
||||
Help,
|
||||
Result { title: String, lines: Vec<String> },
|
||||
DirtyLeave { target: LeaveTarget },
|
||||
/// FB4: Esc mid-wizard — leaving discards the wizard's transient selections.
|
||||
ConfirmAbandon,
|
||||
}
|
||||
|
||||
/// Where a dirty-guarded departure lands. Switching sections and quitting both
|
||||
@@ -418,6 +450,7 @@ impl App {
|
||||
Active::Network(NetworkState::new(&cfg, warns))
|
||||
}
|
||||
Screen::Bundle => Active::Bundle(BundleState::new(desktop_profile_present())),
|
||||
Screen::Wizard => Active::Wizard(WizardState::new()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,10 +521,12 @@ impl App {
|
||||
Active::QConnect(s) => s.is_editing(),
|
||||
Active::Network(s) => s.is_editing(),
|
||||
Active::Bundle(s) => s.is_editing(),
|
||||
Active::Wizard(s) => s.is_editing(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The breadcrumb's level-2 node (field label) when an inline edit is active.
|
||||
/// The Wizard uses it for the current STEP name (`Wizard › <step>`).
|
||||
fn active_editing_label(&self) -> Option<&'static str> {
|
||||
match &self.active {
|
||||
Active::Account(s) => s.editing_label(),
|
||||
@@ -500,12 +535,18 @@ impl App {
|
||||
Active::QConnect(s) => s.editing_label(),
|
||||
Active::Network(s) => s.editing_label(),
|
||||
Active::Bundle(s) => s.editing_label(),
|
||||
Active::Wizard(s) => s.editing_label(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the focused content field consumes ←/→ (so ← must not drop focus).
|
||||
/// The Wizard claims ←/→ for step back/next navigation.
|
||||
fn content_uses_horizontal(&self) -> bool {
|
||||
matches!(&self.active, Active::Audio(s) if s.focused_is_buffer())
|
||||
match &self.active {
|
||||
Active::Audio(s) => s.focused_is_buffer(),
|
||||
Active::Wizard(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- key handling --------------------------
|
||||
@@ -544,6 +585,20 @@ impl App {
|
||||
}
|
||||
return LoopCmd::None;
|
||||
}
|
||||
Overlay::ConfirmAbandon => {
|
||||
match key.code {
|
||||
// Quit the wizard: discard its transient state (a fresh
|
||||
// WizardState) and drop focus back to the sidebar.
|
||||
KeyCode::Char('y') | KeyCode::Enter => {
|
||||
self.overlay = Overlay::None;
|
||||
self.enter_screen(Screen::Wizard);
|
||||
self.enter_nav_focus();
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('n') => self.overlay = Overlay::None,
|
||||
_ => {}
|
||||
}
|
||||
return LoopCmd::None;
|
||||
}
|
||||
Overlay::None => {}
|
||||
}
|
||||
|
||||
@@ -593,6 +648,7 @@ impl App {
|
||||
Active::QConnect(s) => s.handle_key(key),
|
||||
Active::Network(s) => s.handle_key(key),
|
||||
Active::Bundle(s) => s.handle_key(key),
|
||||
Active::Wizard(s) => s.handle_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +693,30 @@ impl App {
|
||||
self.spawn_export(dest, include_auth);
|
||||
LoopCmd::None
|
||||
}
|
||||
ScreenAction::WizardProbeHealth => {
|
||||
self.spawn_wizard_health();
|
||||
LoopCmd::None
|
||||
}
|
||||
ScreenAction::WizardDetect => {
|
||||
self.spawn_wizard_detect();
|
||||
LoopCmd::None
|
||||
}
|
||||
ScreenAction::WizardGenConfigs(dacs) => {
|
||||
self.spawn_wizard_configs(dacs);
|
||||
LoopCmd::None
|
||||
}
|
||||
ScreenAction::WizardTestStart => {
|
||||
self.spawn_wizard_test(true);
|
||||
LoopCmd::None
|
||||
}
|
||||
ScreenAction::WizardTestPoll => {
|
||||
self.spawn_wizard_test(false);
|
||||
LoopCmd::None
|
||||
}
|
||||
ScreenAction::WizardAbandon => {
|
||||
self.overlay = Overlay::ConfirmAbandon;
|
||||
LoopCmd::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,6 +862,79 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------- wizard workers (FB4) --------------------------
|
||||
|
||||
/// The heavy audio-stack health probe (shells out to systemctl/aplay/pw-dump)
|
||||
/// — never on the render thread.
|
||||
fn spawn_wizard_health(&mut self) {
|
||||
self.busy = Some(s::WIZ_HEALTH_CHECKING.to_string());
|
||||
let tx = self.tx.clone();
|
||||
self.handle.spawn_blocking(move || {
|
||||
let _ = tx.send(Msg::WizardHealth(qbz_audio::audio_stack_health()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Enumerate DAC candidates via the pw-dump-robust path (blocking).
|
||||
fn spawn_wizard_detect(&mut self) {
|
||||
self.busy = Some(s::WIZ_DETECTING.to_string());
|
||||
let tx = self.tx.clone();
|
||||
self.handle.spawn_blocking(move || {
|
||||
let _ = tx.send(Msg::WizardDacs(wizard_core::detect_blocking()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-probe rates + build the per-DAC config blocks (blocking).
|
||||
fn spawn_wizard_configs(&mut self, dacs: Vec<(String, String)>) {
|
||||
self.busy = Some(s::WIZ_GENERATING.to_string());
|
||||
let tx = self.tx.clone();
|
||||
self.handle.spawn_blocking(move || {
|
||||
let _ = tx.send(Msg::WizardConfigs(wizard_core::gen_configs_blocking(dacs)));
|
||||
});
|
||||
}
|
||||
|
||||
/// Test read-back: optionally cold-start the daemon's OWN playback of the
|
||||
/// current queue, then sample the requested rate (`/api/status`) and the
|
||||
/// DAC's REAL negotiated rate (`/proc/asound` via qbz-audio::dac_probe). The
|
||||
/// requested-vs-negotiated pair is the bit-perfect proof (N6).
|
||||
fn spawn_wizard_test(&mut self, start_playback: bool) {
|
||||
self.busy = Some(if start_playback {
|
||||
"starting playback…".to_string()
|
||||
} else {
|
||||
"reading DAC…".to_string()
|
||||
});
|
||||
let roots = self.roots.clone();
|
||||
let tx = self.tx.clone();
|
||||
self.handle.spawn(async move {
|
||||
let client = ApiClient::new(None, &roots);
|
||||
let mut note = None;
|
||||
if start_playback {
|
||||
match client.post("/api/playback/play", json!({})).await {
|
||||
Ok(_) => {
|
||||
// Give the stream a moment to open on the DAC before the read.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
|
||||
}
|
||||
Err(CliError::Unreachable(_)) => {
|
||||
note = Some("daemon not reachable — start it, then try the test".to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
note = Some(
|
||||
"nothing to play — queue a track or cast to the daemon first".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Requested (what QBZ asked the daemon for), while playing.
|
||||
let requested = client.get("/api/status").await.ok().and_then(|st| {
|
||||
let sr = st.pointer("/audio/sample_rate").and_then(Value::as_u64)?;
|
||||
let bd = st.pointer("/audio/bit_depth").and_then(Value::as_u64).unwrap_or(0);
|
||||
Some((sr as u32, bd as u32))
|
||||
});
|
||||
// Negotiated (the DAC's real hardware clock).
|
||||
let negotiated = qbz_audio::negotiated_active_rate();
|
||||
let _ = tx.send(Msg::WizardTest { requested, negotiated, note });
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------- worker results --------------------------
|
||||
|
||||
pub fn drain_worker(&mut self) {
|
||||
@@ -894,6 +1047,30 @@ impl App {
|
||||
lines,
|
||||
};
|
||||
}
|
||||
Msg::WizardHealth(health) => {
|
||||
self.busy = None;
|
||||
if let Active::Wizard(w) = &mut self.active {
|
||||
w.set_health(health);
|
||||
}
|
||||
}
|
||||
Msg::WizardDacs(dacs) => {
|
||||
self.busy = None;
|
||||
if let Active::Wizard(w) = &mut self.active {
|
||||
w.set_candidates(dacs);
|
||||
}
|
||||
}
|
||||
Msg::WizardConfigs(configs) => {
|
||||
self.busy = None;
|
||||
if let Active::Wizard(w) = &mut self.active {
|
||||
w.set_configs(configs);
|
||||
}
|
||||
}
|
||||
Msg::WizardTest { requested, negotiated, note } => {
|
||||
self.busy = None;
|
||||
if let Active::Wizard(w) = &mut self.active {
|
||||
w.set_test_result(requested, negotiated, note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +1160,7 @@ impl App {
|
||||
Active::QConnect(sc) => sc.draw(f, inner, &ctx),
|
||||
Active::Network(sc) => sc.draw(f, inner, &ctx),
|
||||
Active::Bundle(sc) => sc.draw(f, inner, &ctx),
|
||||
Active::Wizard(sc) => sc.draw(f, inner, &ctx),
|
||||
}
|
||||
|
||||
self.draw_footer(f, rows[3]);
|
||||
@@ -1005,6 +1183,9 @@ impl App {
|
||||
Overlay::DirtyLeave { .. } => {
|
||||
widgets::modal(f, area, s::DIRTY_TITLE, s::DIRTY_BODY, s::DIRTY_HINT);
|
||||
}
|
||||
Overlay::ConfirmAbandon => {
|
||||
widgets::modal(f, area, s::WIZ_ABANDON_TITLE, s::WIZ_ABANDON_BODY, s::WIZ_ABANDON_HINT);
|
||||
}
|
||||
Overlay::None => {}
|
||||
}
|
||||
|
||||
@@ -1118,6 +1299,7 @@ impl App {
|
||||
s::HELP_AUDIO_CLEAN
|
||||
}
|
||||
}
|
||||
Active::Wizard(sc) => sc.help_text(),
|
||||
_ => {
|
||||
if self.active_is_dirty() {
|
||||
s::HELP_CONTENT_DIRTY
|
||||
@@ -1518,10 +1700,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn number_keys_jump_from_any_focus_but_not_while_editing() {
|
||||
// 1-6 jump from nav and from content (not editing).
|
||||
// 1-7 jump from nav and from content (not editing).
|
||||
assert_eq!(classify_key(Focus::Nav, KeyCode::Char('3'), false, false), NavIntent::JumpSection(2));
|
||||
assert_eq!(classify_key(Focus::Content, KeyCode::Char('1'), false, false), NavIntent::JumpSection(0));
|
||||
assert_eq!(classify_key(Focus::Content, KeyCode::Char('6'), false, false), NavIntent::JumpSection(5));
|
||||
// 7 reaches the FB4 Wizard section (the seventh).
|
||||
assert_eq!(classify_key(Focus::Content, KeyCode::Char('7'), false, false), NavIntent::JumpSection(6));
|
||||
// While a field editor is open, digits are typed into it, not swallowed.
|
||||
assert_eq!(classify_key(Focus::Content, KeyCode::Char('5'), true, false), NavIntent::ToScreen);
|
||||
// ...and so is every other key while editing.
|
||||
@@ -1545,6 +1729,7 @@ mod tests {
|
||||
Screen::QConnect => Active::QConnect(QConnectState::new(false, None, None)),
|
||||
Screen::Network => Active::Network(NetworkState::new(&QbzdConfig::default(), Vec::new())),
|
||||
Screen::Bundle => Active::Bundle(BundleState::new(false)),
|
||||
Screen::Wizard => Active::Wizard(WizardState::new()),
|
||||
};
|
||||
App {
|
||||
roots: ProfileRoots {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// crates/qbzd/src/tui/clipboard.rs — SSH-first tiered clipboard for the wizard's
|
||||
// config blocks (FB4). The daemon's primary operator drives it over SSH, so
|
||||
// OSC 52 (a terminal escape the SSH client's terminal honours) leads there;
|
||||
// on a local session the native tools (`wl-copy`/`xclip`) lead because local
|
||||
// terminals often don't honour OSC 52. Every path ends at a file save so a copy
|
||||
// NEVER errors out of the flow — the operator is always told which tier worked.
|
||||
//
|
||||
// Two pure, unit-tested pieces: `osc52_payload` (base64 + optional tmux
|
||||
// passthrough wrapping) and `plan_tiers` (env-driven ordering).
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// One clipboard mechanism, in preference groups.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tier {
|
||||
/// OSC 52 escape written to the controlling tty — works across SSH.
|
||||
Osc52,
|
||||
/// `wl-copy` (Wayland).
|
||||
WlCopy,
|
||||
/// `xclip -selection clipboard` (X11).
|
||||
Xclip,
|
||||
/// Guaranteed fallback: write to `~/qbzd-wizard/<name>.conf`.
|
||||
File,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
/// Short label for the per-block `copied ✓` flash.
|
||||
pub fn short_label(self) -> &'static str {
|
||||
match self {
|
||||
Tier::Osc52 => "OSC 52",
|
||||
Tier::WlCopy => "wl-copy",
|
||||
Tier::Xclip => "xclip",
|
||||
Tier::File => "saved to file",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard-relevant environment, sampled once. Pure input to `plan_tiers`
|
||||
/// so the ordering is testable without touching the real environment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ClipEnv {
|
||||
pub ssh: bool,
|
||||
pub tmux: bool,
|
||||
pub wayland: bool,
|
||||
pub x11: bool,
|
||||
}
|
||||
|
||||
impl ClipEnv {
|
||||
/// Read the live environment: SSH_TTY/SSH_CONNECTION, TMUX, WAYLAND_DISPLAY,
|
||||
/// DISPLAY.
|
||||
pub fn from_env() -> Self {
|
||||
let has = |k: &str| std::env::var_os(k).map(|v| !v.is_empty()).unwrap_or(false);
|
||||
ClipEnv {
|
||||
ssh: has("SSH_TTY") || has("SSH_CONNECTION"),
|
||||
tmux: has("TMUX"),
|
||||
wayland: has("WAYLAND_DISPLAY"),
|
||||
x11: has("DISPLAY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordered tiers to attempt, SSH-first. Remote (SSH/tmux) leads with OSC 52
|
||||
/// since it is the one that survives the hop; a local session leads with the
|
||||
/// native tool for the running display server. Always ends at `File` so a copy
|
||||
/// can never fail out of the flow.
|
||||
pub fn plan_tiers(env: &ClipEnv) -> Vec<Tier> {
|
||||
let remote = env.ssh || env.tmux;
|
||||
let mut tiers = Vec::new();
|
||||
if remote {
|
||||
tiers.push(Tier::Osc52);
|
||||
} else {
|
||||
if env.wayland {
|
||||
tiers.push(Tier::WlCopy);
|
||||
}
|
||||
if env.x11 {
|
||||
tiers.push(Tier::Xclip);
|
||||
}
|
||||
tiers.push(Tier::Osc52);
|
||||
}
|
||||
tiers.push(Tier::File);
|
||||
tiers
|
||||
}
|
||||
|
||||
/// Standard base64 with padding (no external crate — keeps the slim `qbzd`
|
||||
/// dependency set, and the payload builder must stay pure/testable).
|
||||
fn base64(input: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
||||
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(T[((n >> 18) & 63) as usize] as char);
|
||||
out.push(T[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the OSC 52 clipboard-set escape for `data`. When `tmux` is true, wrap
|
||||
/// it in tmux's DCS passthrough (`\ePtmux;…\e\\`) with every inner ESC doubled,
|
||||
/// so the sequence reaches the outer terminal instead of being swallowed by
|
||||
/// tmux. Base64 keeps arbitrary bytes chunk-safe on the wire.
|
||||
pub fn osc52_payload(data: &str, tmux: bool) -> String {
|
||||
let b64 = base64(data.as_bytes());
|
||||
let seq = format!("\x1b]52;c;{b64}\x07");
|
||||
if tmux {
|
||||
let inner = seq.replace('\x1b', "\x1b\x1b");
|
||||
format!("\x1bPtmux;{inner}\x1b\\")
|
||||
} else {
|
||||
seq
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory the `w` (write) action and the file-fallback tier save into —
|
||||
/// ALWAYS under the operator's home, NEVER a system path (HARD RULE: the wizard
|
||||
/// never writes a live config file).
|
||||
pub fn wizard_dir() -> PathBuf {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join("qbzd-wizard")
|
||||
}
|
||||
|
||||
/// Write `text` to `~/qbzd-wizard/<stem>.conf`, creating the dir. Returns the
|
||||
/// full path so the caller can print it verbatim.
|
||||
pub fn write_wizard_file(stem: &str, text: &str) -> std::io::Result<PathBuf> {
|
||||
let dir = wizard_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let safe: String = stem
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
|
||||
.collect();
|
||||
let stem = if safe.trim_matches('-').is_empty() { "dac".to_string() } else { safe };
|
||||
let path = dir.join(format!("{stem}.conf"));
|
||||
std::fs::write(&path, text)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// What a copy attempt did, so the operator is always told which tier worked.
|
||||
pub struct CopyReport {
|
||||
pub tier: Tier,
|
||||
/// A one-line, human message ("copied to clipboard (OSC 52)", "clipboard
|
||||
/// unavailable — saved to <path>").
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Copy `text` to the clipboard via the best available tier. `stem` names the
|
||||
/// file-fallback save. Never returns an error: the last tier is a file write,
|
||||
/// and even if THAT fails the report says so rather than losing the flow.
|
||||
pub fn copy(text: &str, stem: &str, env: &ClipEnv) -> CopyReport {
|
||||
for tier in plan_tiers(env) {
|
||||
match try_tier(tier, text, stem, env.tmux) {
|
||||
Some(detail) => return CopyReport { tier, detail },
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
// plan_tiers always ends in File; reaching here means even the file write
|
||||
// failed. Report it — do not panic, do not lose the operator's flow.
|
||||
CopyReport {
|
||||
tier: Tier::File,
|
||||
detail: "could not copy or save the block".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt one tier. `Some(detail)` on success, `None` to fall through.
|
||||
fn try_tier(tier: Tier, text: &str, stem: &str, tmux: bool) -> Option<String> {
|
||||
match tier {
|
||||
Tier::Osc52 => write_osc52(text, tmux).ok().map(|()| {
|
||||
let via = if tmux { "OSC 52 via tmux" } else { "OSC 52" };
|
||||
format!("copied to clipboard ({via})")
|
||||
}),
|
||||
Tier::WlCopy => pipe_to("wl-copy", &[], text)
|
||||
.then(|| "copied to clipboard (wl-copy)".to_string()),
|
||||
Tier::Xclip => pipe_to("xclip", &["-selection", "clipboard"], text)
|
||||
.then(|| "copied to clipboard (xclip)".to_string()),
|
||||
Tier::File => write_wizard_file(stem, text)
|
||||
.ok()
|
||||
.map(|p| format!("clipboard unavailable — saved to {}", p.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the OSC 52 escape to the controlling tty (`/dev/tty`) so it reaches the
|
||||
/// terminal even under the ratatui alt-screen (it is an escape, not drawn text).
|
||||
fn write_osc52(text: &str, tmux: bool) -> std::io::Result<()> {
|
||||
let payload = osc52_payload(text, tmux);
|
||||
let mut tty = std::fs::OpenOptions::new().write(true).open("/dev/tty")?;
|
||||
tty.write_all(payload.as_bytes())?;
|
||||
tty.flush()
|
||||
}
|
||||
|
||||
/// Spawn `cmd args`, feed `text` on stdin, discard its output. Both `wl-copy`
|
||||
/// and `xclip` fork a background holder after reading stdin, so `wait()`
|
||||
/// returns promptly. `true` on spawn+write+exit success.
|
||||
fn pipe_to(cmd: &str, args: &[&str], text: &str) -> bool {
|
||||
let child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false, // not installed → fall through
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
if stdin.write_all(text.as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Dropping stdin closes the pipe; the holder daemon detaches, so wait() ends.
|
||||
matches!(child.wait(), Ok(status) if status.success())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_matches_known_vectors() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foob"), "Zm9vYg==");
|
||||
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_payload_wraps_base64_in_the_escape() {
|
||||
let p = osc52_payload("foobar", false);
|
||||
assert_eq!(p, "\x1b]52;c;Zm9vYmFy\x07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_payload_tmux_passthrough_doubles_esc() {
|
||||
let p = osc52_payload("foobar", true);
|
||||
// DCS passthrough envelope, ST terminator, inner ESC doubled.
|
||||
assert!(p.starts_with("\x1bPtmux;"));
|
||||
assert!(p.ends_with("\x1b\\"));
|
||||
// The inner OSC introducer's ESC is doubled inside the envelope
|
||||
// (`;` from the `Ptmux;` prefix, then the doubled ESC, then the OSC).
|
||||
assert!(p.contains(";\x1b\x1b]52;c;Zm9vYmFy\x07"));
|
||||
// Exactly the envelope's two structural ESCs remain single: the leading
|
||||
// `\x1bP` and the trailing `\x1b\\`; every ESC in between is doubled, so
|
||||
// no single ESC is adjacent to a non-ESC in the inner body.
|
||||
assert_eq!(p.matches('\x1b').count(), 4); // \x1bP + \x1b\x1b + \x1b\\
|
||||
// The exact wrapped payload.
|
||||
assert_eq!(p, "\x1bPtmux;\x1b\x1b]52;c;Zm9vYmFy\x07\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_tiers_is_ssh_first_remote() {
|
||||
let ssh = ClipEnv { ssh: true, tmux: false, wayland: true, x11: true };
|
||||
assert_eq!(plan_tiers(&ssh), vec![Tier::Osc52, Tier::File]);
|
||||
let tmux = ClipEnv { ssh: false, tmux: true, wayland: false, x11: false };
|
||||
assert_eq!(plan_tiers(&tmux), vec![Tier::Osc52, Tier::File]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_tiers_prefers_native_tools_locally() {
|
||||
let wayland = ClipEnv { ssh: false, tmux: false, wayland: true, x11: true };
|
||||
assert_eq!(plan_tiers(&wayland), vec![Tier::WlCopy, Tier::Xclip, Tier::Osc52, Tier::File]);
|
||||
let x11 = ClipEnv { ssh: false, tmux: false, wayland: false, x11: true };
|
||||
assert_eq!(plan_tiers(&x11), vec![Tier::Xclip, Tier::Osc52, Tier::File]);
|
||||
let headless = ClipEnv { ssh: false, tmux: false, wayland: false, x11: false };
|
||||
assert_eq!(plan_tiers(&headless), vec![Tier::Osc52, Tier::File]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_tiers_always_ends_in_file() {
|
||||
for ssh in [false, true] {
|
||||
for tmux in [false, true] {
|
||||
for wayland in [false, true] {
|
||||
for x11 in [false, true] {
|
||||
let env = ClipEnv { ssh, tmux, wayland, x11 };
|
||||
assert_eq!(*plan_tiers(&env).last().unwrap(), Tier::File);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@
|
||||
// short-lived `block_on` — never on a keystroke.
|
||||
|
||||
pub mod app;
|
||||
pub mod clipboard;
|
||||
pub mod strings;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod wizard_core;
|
||||
|
||||
mod screens;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// crates/qbzd/src/tui/screens/mod.rs — the six setup screens (D7 hard cap).
|
||||
// crates/qbzd/src/tui/screens/mod.rs — the seven setup screens (FB4 added the
|
||||
// HiFi Wizard over the old six-screen D7 cap, owner-sanctioned).
|
||||
pub mod account;
|
||||
pub mod audio;
|
||||
pub mod bundle;
|
||||
pub mod network;
|
||||
pub mod playback;
|
||||
pub mod qconnect;
|
||||
pub mod wizard;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,21 +27,23 @@ pub const HELP_TITLE: &str = "Help";
|
||||
/// while editing, the field label) carries the accent.
|
||||
pub const BREADCRUMB_ROOT: &str = "Setup";
|
||||
|
||||
/// Persistent left-nav labels (fixed order — the D7 six-screen hard cap). NAME
|
||||
/// only; the old menu's live summaries are dropped — the content is the detail.
|
||||
/// Dirty-capable sections (Audio/Playback/QConnect/Network) stay ≤ 8 chars so a
|
||||
/// trailing `*` fits the 14-col sidebar; Account/Import are never dirty.
|
||||
pub const SIDEBAR_LABELS: [&str; 6] = [
|
||||
/// Persistent left-nav labels (fixed order). NAME only; the old menu's live
|
||||
/// summaries are dropped — the content is the detail. Dirty-capable sections
|
||||
/// (Audio/Playback/QConnect/Network) stay ≤ 8 chars so a trailing `*` fits the
|
||||
/// 14-col sidebar; Account/Import/Wizard are never dirty. Seven since FB4 added
|
||||
/// the HiFi Wizard (owner-sanctioned cap break over the old six-screen D7 cap).
|
||||
pub const SIDEBAR_LABELS: [&str; 7] = [
|
||||
"Account",
|
||||
"Audio",
|
||||
"Playback",
|
||||
"QConnect",
|
||||
"Network",
|
||||
"Import/Exp",
|
||||
"Wizard",
|
||||
];
|
||||
|
||||
// Global help-bar hints (context-sensitive; assembled per focus + screen).
|
||||
pub const HELP_NAV: &str = "up/down move · Enter open · 1-6 jump · Tab content · ? help · q quit";
|
||||
pub const HELP_NAV: &str = "up/down move · Enter open · 1-7 jump · Tab content · ? help · q quit";
|
||||
pub const HELP_CONTENT_CLEAN: &str = "up/down move · Enter edit · Tab nav · Esc nav · ? help · q quit";
|
||||
pub const HELP_CONTENT_DIRTY: &str = "up/down move · Enter edit · s SAVE* · Tab nav · Esc nav · q quit";
|
||||
pub const HELP_AUDIO_CLEAN: &str =
|
||||
@@ -58,7 +60,7 @@ pub const HELP_OVERLAY: &str = "GLOBAL KEYS
|
||||
Enter open a section / edit a field / confirm
|
||||
Tab toggle sidebar <-> content
|
||||
Esc content: back to sidebar · sidebar: quit
|
||||
1 - 6 jump straight to a section
|
||||
1 - 7 jump straight to a section
|
||||
s save the current section
|
||||
r refresh (Audio: re-enumerate devices)
|
||||
/ filter (device picker)
|
||||
@@ -295,3 +297,100 @@ pub const SAVED_DISK_ONLY: &str =
|
||||
"saved to disk — daemon didn't answer; changes apply on restart";
|
||||
pub const RELOAD_REFUSED: &str =
|
||||
"saved to disk — daemon answered but refused the reload; restart it:\n systemctl --user restart qbzd";
|
||||
|
||||
// ============================ HiFi Wizard (FB4, §7) ============================
|
||||
|
||||
pub const WIZARD_TITLE: &str = "Wizard";
|
||||
|
||||
// Step names (breadcrumb `Wizard › <step>`).
|
||||
pub const WIZ_STEP_WELCOME: &str = "Welcome";
|
||||
pub const WIZ_STEP_CHECK: &str = "Check";
|
||||
pub const WIZ_STEP_SELECT: &str = "Select DACs";
|
||||
pub const WIZ_STEP_REVIEW: &str = "Review";
|
||||
pub const WIZ_STEP_TEST: &str = "Test";
|
||||
pub const WIZ_STEP_DONE: &str = "Done";
|
||||
|
||||
// Per-step help bars.
|
||||
pub const WIZ_HELP_WELCOME: &str = "Enter start · → next · Tab nav · q quit";
|
||||
pub const WIZ_HELP_CHECK: &str = "up/down move · Enter override · → next · ← back · Esc quit wizard";
|
||||
pub const WIZ_HELP_SELECT: &str = "up/down move · Space toggle · m manual · → next · ← back · Esc quit";
|
||||
pub const WIZ_HELP_REVIEW: &str =
|
||||
"up/down block · c copy · C copy all · w save · PgUp/PgDn scroll · → next · ← back";
|
||||
pub const WIZ_HELP_TEST: &str = "t play test · r re-read · → next (skip) · ← back · Esc quit wizard";
|
||||
pub const WIZ_HELP_DONE: &str = "Enter finish · ← back · Esc close";
|
||||
|
||||
// Welcome step.
|
||||
pub const WIZ_WELCOME_TITLE: &str = "HiFi / DAC Setup Wizard";
|
||||
pub const WIZ_WELCOME_BODY: &str = "This wizard checks your PipeWire/ALSA audio stack, finds your DAC(s), and\ngenerates the exact bit-perfect config for each one. It never touches a system\nfile — you copy the blocks and apply them yourself.\n\nSteps: Check the stack · Select DACs · Review the config · Test playback.";
|
||||
pub const WIZ_WELCOME_CTA: &str = "Enter start";
|
||||
|
||||
// Check step.
|
||||
pub const WIZ_DISTRO: &str = "Distribution";
|
||||
pub const WIZ_INIT: &str = "Init system";
|
||||
pub const WIZ_HEALTH_CHECKING: &str = "checking your audio stack…";
|
||||
pub const WIZ_HEALTH_READY: &str = "✓ your audio stack is ready for bit-perfect playback";
|
||||
pub const WIZ_HEALTH_ATTENTION: &str = "! some pieces need attention before bit-perfect playback will work:";
|
||||
pub const WIZ_NO_REMEDIATION: &str = "nothing to change — the commands below are for reference only.";
|
||||
|
||||
pub fn wiz_sandbox_note(name: &str) -> String {
|
||||
format!(
|
||||
"running inside {name} — the host audio stack can't be probed from here; \
|
||||
the commands below are the reference setup for the distro/init you pick."
|
||||
)
|
||||
}
|
||||
|
||||
// Select-DACs step.
|
||||
pub const WIZ_SELECT_INTRO: &str = "Detected outputs — check the DAC(s) you want bit-perfect config for:";
|
||||
pub const WIZ_DETECTING: &str = "detecting DACs…";
|
||||
pub const WIZ_DAC_BADGE: &str = " [likely DAC]";
|
||||
pub const WIZ_DEFAULT_BADGE: &str = " [default]";
|
||||
pub const WIZ_NO_DACS: &str = "no outputs enumerated — is PipeWire running and pw-dump installed?\nyou can still enter a node.name manually with 'm'.";
|
||||
pub const WIZ_MANUAL_HINT: &str = "m — enter a PipeWire node.name manually (alsa_output.* / alsa_input.*)";
|
||||
pub const WIZ_MANUAL_ACCEPTED: &str = "manual node:";
|
||||
pub const WIZ_MANUAL_TITLE: &str = "Manual node.name";
|
||||
pub const WIZ_MANUAL_BODY: &str = "Paste a PipeWire node.name (must contain alsa_output or alsa_input):";
|
||||
pub const WIZ_MANUAL_INVALID: &str = "not a valid node.name — it must contain alsa_output or alsa_input";
|
||||
pub const WIZ_SELECT_GATE: &str = "select at least one DAC (or enter a node.name with 'm') before continuing";
|
||||
|
||||
// Review step.
|
||||
pub const WIZ_GENERATING: &str = "generating per-DAC config…";
|
||||
pub const WIZ_BACKUP_HINT: &str = "tip: back up ~/.config/pipewire + ~/.config/wireplumber before applying anything.";
|
||||
pub const WIZ_REVIEW_FOOTER: &str =
|
||||
"the wizard NEVER writes these files — copy (c/C) or save (w), then apply them yourself";
|
||||
pub const WIZ_SAVED_TO: &str = "saved to";
|
||||
pub const WIZ_SAVE_FAILED: &str = "could not save";
|
||||
|
||||
pub fn wiz_copied_all(n: usize) -> String {
|
||||
if n == 1 {
|
||||
"copied 1 block".to_string()
|
||||
} else {
|
||||
format!("copied all {n} blocks")
|
||||
}
|
||||
}
|
||||
|
||||
// Test step.
|
||||
pub const WIZ_TEST_INTRO: &str = "Play a track through the daemon, then read the DAC's REAL negotiated rate back\n(from /proc/asound) — the requested vs negotiated rate is the bit-perfect proof.";
|
||||
pub const WIZ_TEST_NOTHING: &str = "nothing playing yet — press t to start the current queue";
|
||||
pub const WIZ_TEST_WAITING: &str = "waiting for the DAC to open a stream…";
|
||||
pub const WIZ_TEST_MATCHED: &str = "✓ the DAC clock matches what QBZ requested — bit-perfect";
|
||||
pub const WIZ_TEST_REFERENCE: &str = "known reference track:";
|
||||
pub const WIZ_TEST_SEEDS_HEADER: &str = "reference tracks you can cast/queue to verify each rate:";
|
||||
|
||||
// Done step.
|
||||
pub const WIZ_DONE_TITLE: &str = "All set";
|
||||
pub const WIZ_DONE_REMINDER: &str = "reminder: QBZ never writes system audio configs. Apply the blocks you copied,\nthen restart your PipeWire/WirePlumber user services (or log out and back in).";
|
||||
pub const WIZ_DONE_RESTART: &str = "on this box, that is:";
|
||||
pub const WIZ_DONE_CTA: &str = "Enter finish";
|
||||
|
||||
pub fn wiz_done_summary(dacs: usize) -> String {
|
||||
match dacs {
|
||||
0 => "No DAC config was generated — re-run the wizard to select a DAC.".to_string(),
|
||||
1 => "Generated bit-perfect config for 1 DAC.".to_string(),
|
||||
n => format!("Generated bit-perfect config for {n} DACs."),
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm-abandon modal (Esc mid-wizard).
|
||||
pub const WIZ_ABANDON_TITLE: &str = "Quit the wizard?";
|
||||
pub const WIZ_ABANDON_BODY: &str = "Your selections and generated config will be discarded.";
|
||||
pub const WIZ_ABANDON_HINT: &str = "y quit · Esc stay";
|
||||
@@ -0,0 +1,629 @@
|
||||
// crates/qbzd/src/tui/wizard_core.rs — the frontend-agnostic HiFi-wizard logic.
|
||||
//
|
||||
// COPIED from crates/qbz-dac-wizard/src/lib.rs @ 7678bceb (the Slint controller)
|
||||
// TODO(converge: dac-wizard) — fold into a shared crate (P1). The Slint crate
|
||||
// depends on `slint` + `qbz-ui` (it drives `DacWizardState` globals), which the
|
||||
// slint-free `qbzd` column must NOT link. So the window-free PURE pieces are
|
||||
// copied here and the imperative `DacWizardState` plumbing (open_immediate /
|
||||
// apply_health / recompute / apply_candidates / apply_configs / apply_poll / …)
|
||||
// is re-expressed as plain data the TUI screen owns.
|
||||
//
|
||||
// Adaptations vs the original (all minimal, none touch the emitted config text):
|
||||
// 1. `qbz_i18n::t("…")` / `tf(…)` calls dropped — the setup TUI is English-only
|
||||
// (03 §1.2), so caption literals are inlined (`.to_string()`); the plural
|
||||
// summary line is built by the screen, not here.
|
||||
// 2. `DacCandidateData` / `DacConfigData` fields made `pub` so the sibling
|
||||
// screen module can read them (the original read them within one module).
|
||||
// 3. `DacConfigData` gains `full_block()` / `target_paths()` / `short()` so the
|
||||
// TUI can render/copy/save one bordered box per DAC (the Slint accordion had
|
||||
// three sub-blocks + a separate created-paths list).
|
||||
// 4. `seed_for_rate_depth()` added as the non-test call site for the copied
|
||||
// `track_matches_seed()` (the Slint side called it from the search-resolve
|
||||
// path, which the daemon test step does not reproduce).
|
||||
// Everything else — remediation/reference command generation, the three config
|
||||
// generators, slugify/short_name/rates_list, the test seeds — is verbatim.
|
||||
|
||||
use qbz_audio::{AudioStackHealth, Distro, InitSystem, NegotiatedRate};
|
||||
|
||||
// ── select-dacs (auto-detect + manual escape hatch) ────────────────────────
|
||||
|
||||
/// Plain, `Send` candidate produced on the worker thread.
|
||||
pub struct DacCandidateData {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub bus: String,
|
||||
pub is_default: bool,
|
||||
pub looks_like_dac: bool,
|
||||
pub rates_label: String,
|
||||
}
|
||||
|
||||
/// Heavy work (enumerate sinks via the pw-dump-robust path + probe rates for
|
||||
/// the likely DACs). Runs on a blocking thread; returns plain data.
|
||||
pub fn detect_blocking() -> Vec<DacCandidateData> {
|
||||
let devices = qbz_audio::backend::BackendManager::create_backend(
|
||||
qbz_audio::backend::AudioBackendType::PipeWire,
|
||||
)
|
||||
.and_then(|b| b.enumerate_devices())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for d in devices {
|
||||
let bus = d.device_bus.unwrap_or_default();
|
||||
let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci");
|
||||
// Only probe rates for likely DACs (skip virtual/monitor sinks).
|
||||
let rates_label = if looks_like_dac {
|
||||
format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let description = if d.name.is_empty() { d.id.clone() } else { d.name };
|
||||
out.push(DacCandidateData {
|
||||
id: d.id,
|
||||
description,
|
||||
bus,
|
||||
is_default: d.is_default,
|
||||
looks_like_dac,
|
||||
rates_label,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a manually-pasted node.name (escape hatch). 1:1 with the Tauri
|
||||
/// `validateNodeName` / `detectDacType`.
|
||||
pub fn validate_node_name(name: &str) -> bool {
|
||||
let t = name.trim();
|
||||
!t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input"))
|
||||
}
|
||||
|
||||
pub fn detect_dac_type(name: &str) -> &'static str {
|
||||
let l = name.to_lowercase();
|
||||
if l.contains("usb-") || l.contains(".usb") {
|
||||
"usb"
|
||||
} else if l.contains("pci-") || l.contains(".pci") {
|
||||
"pci"
|
||||
} else if l.contains("bluez") || l.contains("bluetooth") {
|
||||
"bluetooth"
|
||||
} else if l.contains("virtual") || l.contains("null") || l.contains("dummy") {
|
||||
"virtual"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer).
|
||||
fn format_rates(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let parts: Vec<String> = rates
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
if r % 1000 == 0 {
|
||||
format!("{}", r / 1000)
|
||||
} else {
|
||||
format!("{:.1}", r as f64 / 1000.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{} kHz", parts.join(" / "))
|
||||
}
|
||||
|
||||
// ── check step: audio-stack remediations (per distro / init) ────────────────
|
||||
|
||||
/// (caption, copy-paste command) per missing probe, for the given distro.
|
||||
///
|
||||
/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo,
|
||||
/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector
|
||||
/// `restartCommands`. Installs and the restart are kept as separate blocks so
|
||||
/// the multi-line Gentoo guidance never gets `&&`-joined.
|
||||
pub fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec<(String, String)> {
|
||||
// NixOS is declarative: you don't `apt/pacman install` pieces — you enable
|
||||
// the PipeWire module and rebuild. So collapse all the missing pieces into
|
||||
// one config block instead of per-package commands.
|
||||
if d == Distro::NixOS {
|
||||
if h.is_ready() {
|
||||
return Vec::new();
|
||||
}
|
||||
return vec![(
|
||||
"Enable PipeWire in your NixOS configuration".to_string(),
|
||||
NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
)];
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut needs_restart = false;
|
||||
if !h.has_pw_dump {
|
||||
out.push((
|
||||
"Install the PipeWire tools (pw-dump)".to_string(),
|
||||
install(d, pkg_pw_tools(d)),
|
||||
));
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.cpal_sees_pipewire {
|
||||
// THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM.
|
||||
out.push((
|
||||
"Install the ALSA bridge so playback can reach PipeWire".to_string(),
|
||||
install(d, "pipewire-alsa"),
|
||||
));
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.has_pactl {
|
||||
out.push((
|
||||
"Install the Pulse compatibility tools (optional fallback)".to_string(),
|
||||
install(d, pkg_pulse(d)),
|
||||
));
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.any_devices {
|
||||
out.push((
|
||||
"No sinks detected — reinstall the ALSA UCM profiles, then reboot".to_string(),
|
||||
install_reinstall(d, "alsa-ucm-conf"),
|
||||
));
|
||||
}
|
||||
// WirePlumber down, or we just installed something → (re)start the stack
|
||||
// with the ACTUAL init system running on this machine (not guessed from the
|
||||
// distro — Gentoo+systemd and Gentoo+OpenRC must differ).
|
||||
if !h.wireplumber_active || needs_restart {
|
||||
out.push((
|
||||
"(Re)start the PipeWire audio services".to_string(),
|
||||
restart_cmd(init).to_string(),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Init-system-aware "(re)start the audio services" command. PipeWire is a
|
||||
/// user-session service, so only systemd has a first-class `--user` restart;
|
||||
/// the others either use their user-service supervisor or a re-login.
|
||||
pub fn restart_cmd(init: InitSystem) -> &'static str {
|
||||
match init {
|
||||
InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber",
|
||||
InitSystem::OpenRc => {
|
||||
"# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\
|
||||
# Log out and back in to restart it."
|
||||
}
|
||||
InitSystem::Runit => {
|
||||
"sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in"
|
||||
}
|
||||
InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in",
|
||||
InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in",
|
||||
InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in",
|
||||
}
|
||||
}
|
||||
|
||||
fn pkg_pw_tools(d: Distro) -> &'static str {
|
||||
match d {
|
||||
// Debian-family (incl. antiX) ship pw-* in pipewire-bin.
|
||||
Distro::Debian | Distro::Antix => "pipewire-bin",
|
||||
Distro::Fedora => "pipewire-utils",
|
||||
// Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire.
|
||||
_ => "pipewire",
|
||||
}
|
||||
}
|
||||
|
||||
fn pkg_pulse(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils",
|
||||
Distro::Fedora => "pipewire-pulseaudio",
|
||||
_ => "pipewire-pulse",
|
||||
}
|
||||
}
|
||||
|
||||
fn install(d: Distro, pkgs: &str) -> String {
|
||||
match d {
|
||||
// Package manager is a property of the distro family, NOT the init.
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"),
|
||||
Distro::Fedora => format!("sudo dnf install {pkgs}"),
|
||||
Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"),
|
||||
Distro::OpenSuse => format!("sudo zypper install {pkgs}"),
|
||||
Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"),
|
||||
Distro::Void => format!("sudo xbps-install -S {pkgs}"),
|
||||
// NixOS is special-cased in remediations(); this is an unreached fallback.
|
||||
Distro::NixOS => format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}"),
|
||||
Distro::Other => format!("Install with your package manager: {pkgs}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_reinstall(d: Distro, pkg: &str) -> String {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"),
|
||||
Distro::Fedora => format!("sudo dnf reinstall {pkg}"),
|
||||
_ => install(d, pkg),
|
||||
}
|
||||
}
|
||||
|
||||
const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\
|
||||
services.pipewire = {\n\
|
||||
\u{20}\u{20}enable = true;\n\
|
||||
\u{20}\u{20}alsa.enable = true;\n\
|
||||
\u{20}\u{20}pulse.enable = true;\n\
|
||||
\u{20}\u{20}wireplumber.enable = true;\n\
|
||||
};\n\
|
||||
# then apply:\n\
|
||||
sudo nixos-rebuild switch";
|
||||
|
||||
/// Full reference setup commands for the chosen distro/init, shown when QBZ
|
||||
/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which
|
||||
/// always showed per-distro install + restart commands (no probing).
|
||||
pub fn reference_commands(d: Distro, init: InitSystem) -> Vec<(String, String)> {
|
||||
if d == Distro::NixOS {
|
||||
return vec![(
|
||||
"Enable PipeWire in your NixOS configuration".to_string(),
|
||||
NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
)];
|
||||
}
|
||||
vec![
|
||||
(
|
||||
"Install the PipeWire audio stack".to_string(),
|
||||
install(d, full_stack_pkgs(d)),
|
||||
),
|
||||
(
|
||||
"(Re)start the PipeWire audio services".to_string(),
|
||||
restart_cmd(init).to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The full recommended package set (incl. `pipewire-alsa`, the bit the old
|
||||
/// Tauri list omitted — the cause of the Ubuntu empty-list bug).
|
||||
fn full_stack_pkgs(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Arch | Distro::Artix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils",
|
||||
Distro::Void => "pipewire wireplumber alsa-utils",
|
||||
Distro::NixOS => "",
|
||||
Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils",
|
||||
}
|
||||
}
|
||||
|
||||
// ── review-and-apply (per-DAC config generation) ────────────────────────────
|
||||
|
||||
/// Plain, `Send` per-DAC generated config (built on a worker thread).
|
||||
pub struct DacConfigData {
|
||||
pub name: String,
|
||||
pub node_name: String,
|
||||
pub pipewire_conf: String,
|
||||
pub pulse_conf: String,
|
||||
pub wireplumber_conf: String,
|
||||
}
|
||||
|
||||
impl DacConfigData {
|
||||
/// A short, filename-safe id for this DAC (slug of the description, else the
|
||||
/// node.name). Drives both the on-disk `~/.config/...` paths and the
|
||||
/// TUI's `w` save filename.
|
||||
pub fn short(&self) -> String {
|
||||
short_name(&self.name, &self.node_name)
|
||||
}
|
||||
|
||||
/// The three target files this config populates (verbatim the path format
|
||||
/// the Slint `apply_configs` pushed to `created_paths`).
|
||||
pub fn target_paths(&self) -> Vec<String> {
|
||||
let short = self.short();
|
||||
vec![
|
||||
format!("~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf"),
|
||||
format!("~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf"),
|
||||
format!("~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf"),
|
||||
]
|
||||
}
|
||||
|
||||
/// The whole copy-paste block for this DAC: the three heredoc snippets
|
||||
/// (PipeWire rate-switching, per-app bit-perfect, WirePlumber node pin)
|
||||
/// joined so a single `c`/`w` reproduces every file.
|
||||
pub fn full_block(&self) -> String {
|
||||
[
|
||||
self.pipewire_conf.as_str(),
|
||||
self.pulse_conf.as_str(),
|
||||
self.wireplumber_conf.as_str(),
|
||||
]
|
||||
.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-probe rates + build the three config snippets per DAC (blocking).
|
||||
pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec<DacConfigData> {
|
||||
dacs.into_iter()
|
||||
.map(|(node_name, name)| {
|
||||
let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates;
|
||||
let short = short_name(&name, &node_name);
|
||||
DacConfigData {
|
||||
pipewire_conf: pipewire_conf(&short, &rates),
|
||||
pulse_conf: pulse_conf(&short),
|
||||
wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name),
|
||||
name,
|
||||
node_name,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The backup command shown above the generated blocks (back up the live
|
||||
/// PipeWire/WirePlumber config before the operator applies anything).
|
||||
pub const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\"";
|
||||
|
||||
/// A short, filename-safe DAC name: slug of the description, else the node.name.
|
||||
fn short_name(name: &str, node_name: &str) -> String {
|
||||
let slug = slugify(name);
|
||||
if !slug.is_empty() {
|
||||
return slug;
|
||||
}
|
||||
let nslug = slugify(node_name);
|
||||
if nslug.is_empty() {
|
||||
"dac".to_string()
|
||||
} else {
|
||||
nslug
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn rates_list(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
"44100 48000 88200 96000 176400 192000".to_string()
|
||||
} else {
|
||||
rates
|
||||
.iter()
|
||||
.map(|r| r.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn pipewire_conf(short: &str, rates: &[u32]) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Sample Rate Switching".to_string(),
|
||||
"context.properties = {".to_string(),
|
||||
format!(" default.clock.allowed-rates = [ {rates} ]"),
|
||||
"}".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn pulse_conf(short: &str) -> String {
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/client.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Per-App Bit-Perfect".to_string(),
|
||||
"stream.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
" { application.process.binary = \"qbz\" }".to_string(),
|
||||
" { application.name = \"PipeWire ALSA [qbz]\" }".to_string(),
|
||||
" ]".to_string(),
|
||||
" actions = { update-props = { resample.disable = true, channelmix.disable = true } }"
|
||||
.to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(),
|
||||
format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
format!("# QBZ DAC Setup - {description}"),
|
||||
"monitor.alsa.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"),
|
||||
" ]".to_string(),
|
||||
" actions = {".to_string(),
|
||||
" update-props = {".to_string(),
|
||||
format!(" audio.allowed-rates = [ {rates} ]"),
|
||||
" resample.disable = true".to_string(),
|
||||
" channelmix.disable = true".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// ── self-service playback test (N6 read-back) ───────────────────────────────
|
||||
|
||||
/// One curated test track (owner-provided). Resolved by id-hint first, then by
|
||||
/// "artist title" search if the id 404s (a pulled license) — never raw-id-only.
|
||||
pub struct TestSeed {
|
||||
pub depth: u32,
|
||||
pub rate: f64,
|
||||
pub id_hint: u64,
|
||||
pub artist: &'static str,
|
||||
pub title: &'static str,
|
||||
}
|
||||
|
||||
pub const TEST_SEEDS: [TestSeed; 4] = [
|
||||
TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" },
|
||||
TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" },
|
||||
TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" },
|
||||
TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" },
|
||||
];
|
||||
|
||||
/// True if a resolved track matches this seed's family (rate + bit depth — the
|
||||
/// two 44.1 seeds only differ by depth).
|
||||
pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool {
|
||||
let rate_ok = track
|
||||
.maximum_sampling_rate
|
||||
.map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0)
|
||||
.unwrap_or(false);
|
||||
let depth_ok = track.maximum_bit_depth.map(|d| d == seed.depth).unwrap_or(false);
|
||||
rate_ok && depth_ok
|
||||
}
|
||||
|
||||
/// The curated reference seed a live `(rate_hz, depth)` pair corresponds to, if
|
||||
/// any. Non-test call site for [`track_matches_seed`]: the daemon test step
|
||||
/// reads a hardware rate back (`negotiated_active_rate`) rather than resolving a
|
||||
/// seed track through search, so this labels the played rate as a known
|
||||
/// bit-perfect reference when it lines up.
|
||||
pub fn seed_for_rate_depth(rate_hz: u32, depth: u32) -> Option<&'static TestSeed> {
|
||||
let track = qbz_models::Track {
|
||||
maximum_sampling_rate: Some(rate_hz as f64),
|
||||
maximum_bit_depth: Some(depth),
|
||||
..Default::default()
|
||||
};
|
||||
TEST_SEEDS.iter().find(|s| track_matches_seed(&track, s))
|
||||
}
|
||||
|
||||
/// "192 kHz" / "44.1 kHz" from Hz (shared by the test read-back rendering).
|
||||
pub fn khz(hz: u32) -> String {
|
||||
if hz % 1000 == 0 {
|
||||
format!("{} kHz", hz / 1000)
|
||||
} else {
|
||||
format!("{:.1} kHz", hz as f64 / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The DAC read-back line: real hardware rate · ALSA container format · channels
|
||||
/// (N6). `S32_LE` = 24-bit carried in a 32-bit frame — this is the container, so
|
||||
/// the wizard's "matched" verdict keys on the RATE, not the format string.
|
||||
pub fn negotiated_label(n: &NegotiatedRate) -> String {
|
||||
format!("DAC: {} · {} · {} ch", khz(n.sample_rate), n.format, n.channels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_node_names_like_tauri() {
|
||||
assert!(validate_node_name("alsa_output.usb-Cambridge-00.analog-stereo"));
|
||||
assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo"));
|
||||
assert!(!validate_node_name(""));
|
||||
assert!(!validate_node_name(" "));
|
||||
assert!(!validate_node_name("bluez_output.AA_BB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dac_type() {
|
||||
assert_eq!(detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"), "usb");
|
||||
assert_eq!(detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"), "pci");
|
||||
assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth");
|
||||
assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual");
|
||||
assert_eq!(detect_dac_type("something.else"), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_rates_khz() {
|
||||
assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz");
|
||||
assert_eq!(format_rates(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugifies_descriptions() {
|
||||
assert_eq!(slugify("DacMagic Plus Analog Stereo"), "dacmagic-plus-analog-stereo");
|
||||
assert_eq!(slugify("Built-in Audio Analog Stereo"), "built-in-audio-analog-stereo");
|
||||
assert_eq!(slugify(" weird__name!! "), "weird-name");
|
||||
assert_eq!(slugify(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireplumber_conf_pins_node_and_rates() {
|
||||
let c = wireplumber_conf("dacmagic", "alsa_output.usb-x.analog-stereo", &[44100, 192000], "DacMagic");
|
||||
assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\""));
|
||||
assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]"));
|
||||
assert!(c.contains("99-qbz-dac-dacmagic.conf"));
|
||||
assert!(c.contains("resample.disable = true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_block_and_paths_cover_the_three_files() {
|
||||
let cfg = DacConfigData {
|
||||
name: "DacMagic".to_string(),
|
||||
node_name: "alsa_output.usb-x.analog-stereo".to_string(),
|
||||
pipewire_conf: "PW".to_string(),
|
||||
pulse_conf: "PULSE".to_string(),
|
||||
wireplumber_conf: "WP".to_string(),
|
||||
};
|
||||
assert_eq!(cfg.short(), "dacmagic");
|
||||
let block = cfg.full_block();
|
||||
assert!(block.contains("PW") && block.contains("PULSE") && block.contains("WP"));
|
||||
let paths = cfg.target_paths();
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert!(paths[0].contains("pipewire.conf.d/99-qbz-dac-dacmagic.conf"));
|
||||
assert!(paths[1].contains("client.conf.d/99-qbz-bitperfect-dacmagic.conf"));
|
||||
assert!(paths[2].contains("wireplumber.conf.d/99-qbz-dac-dacmagic.conf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_lookup_matches_known_reference_rates() {
|
||||
// 24/192 → Toto "Africa"; 16/44100 → George Harrison.
|
||||
assert_eq!(seed_for_rate_depth(192000, 24).map(|s| s.title), Some("Africa"));
|
||||
assert_eq!(seed_for_rate_depth(44100, 16).map(|s| s.title), Some("My Sweet Lord"));
|
||||
// The two 44.1 seeds differ only by depth.
|
||||
assert_eq!(seed_for_rate_depth(44100, 24).map(|s| s.title), Some("LUNCH"));
|
||||
// An off-grid rate matches nothing.
|
||||
assert!(seed_for_rate_depth(48000, 24).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remediations_nixos_collapses_to_one_config_block() {
|
||||
let unhealthy = AudioStackHealth {
|
||||
wireplumber_active: false,
|
||||
has_pw_dump: false,
|
||||
cpal_sees_pipewire: false,
|
||||
has_pactl: false,
|
||||
any_devices: false,
|
||||
};
|
||||
let r = remediations(unhealthy, Distro::NixOS, InitSystem::Systemd);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0].1.contains("services.pipewire"));
|
||||
assert!(r[0].1.contains("nixos-rebuild switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remediations_debian_names_the_alsa_bridge_and_is_init_aware() {
|
||||
let missing_bridge = AudioStackHealth {
|
||||
wireplumber_active: true,
|
||||
has_pw_dump: true,
|
||||
cpal_sees_pipewire: false, // the Ubuntu empty-list bug
|
||||
has_pactl: true,
|
||||
any_devices: true,
|
||||
};
|
||||
let r = remediations(missing_bridge, Distro::Debian, InitSystem::Systemd);
|
||||
assert!(r.iter().any(|(_, cmd)| cmd == "sudo apt install pipewire-alsa"));
|
||||
// needs_restart flipped → an init-aware systemd restart block is appended.
|
||||
assert!(r.iter().any(|(_, cmd)| cmd.contains("systemctl --user restart")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_commands_used_in_sandbox_full_stack() {
|
||||
let r = reference_commands(Distro::Debian, InitSystem::Systemd);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert!(r[0].1.contains("pipewire-alsa"), "full stack must include the ALSA bridge");
|
||||
assert!(r[1].1.contains("systemctl --user restart"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiated_label_shows_rate_format_channels() {
|
||||
let n = NegotiatedRate { sample_rate: 192000, format: "S32_LE".to_string(), channels: 2 };
|
||||
assert_eq!(negotiated_label(&n), "DAC: 192 kHz · S32_LE · 2 ch");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user