feat(qbzd): hifi wizard section with copyable config blocks in setup TUI

cc
This commit is contained in:
vicrodh committed 2026-07-15 10:49:38 -06:00
1 parent ff1dbf7425
commit 90412328be
7 files changed
+2292 -15

No files matched your search

+192 -7
View File
@@ -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 {
+286
View File
@@ -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);
}
}
}
}
}
}
+2
View 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;
+3 -1
View File
@@ -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
+106 -7
View File
@@ -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";
+629
View File
@@ -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");
}
}