Files
qbz/crates/qbz-app/src/diagnostics.rs
T
vicrodh 0e8614fe37 feat(audio): add guarded ALSA hardware volume control
Probe exact mixer element identities, retain stable device routes, and fail closed for ambiguous or switch-only controls. Synchronize player volume through ALSA events without changing direct PCM configuration.

cx
2026-08-30 09:14:16 -06:00

375 lines
15 KiB
Rust

//! Frontend-agnostic diagnostics snapshot builders.
//!
//! This module is a faithful COPY of the Tauri diagnostics backend
//! (`src-tauri/src/commands_v2/diagnostics.rs`) so the Slint `qbz` bin can
//! produce byte-identical `RuntimeDiagnostics` + `SystemInfo` snapshots
//! headlessly. The Tauri source stays untouched as the reference copy.
//!
//! Everything here is pure: std + `/proc` + `/sys` + `/etc/os-release` +
//! `/proc/self/maps`, plus three settings structs passed in by the caller.
//! No `tauri::` types, no `crate::runtime::RuntimeError`. Both builders are
//! infallible and return their struct directly.
//!
//! The `#[serde(rename_all = "camelCase")]` on both structs is load-bearing:
//! the exported JSON keys (and the existing Svelte TS interface) depend on it,
//! so the shared struct keeps the same derive + rename for a byte-identical
//! export.
// ==================== Runtime Diagnostics ====================
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeDiagnostics {
// Audio: saved settings
pub audio_output_device: Option<String>,
pub audio_backend_type: Option<String>,
pub audio_exclusive_mode: bool,
pub audio_dac_passthrough: bool,
pub audio_preferred_sample_rate: Option<u32>,
pub audio_alsa_plugin: Option<String>,
pub audio_alsa_hardware_volume: bool,
pub audio_alsa_hardware_volume_controls: std::collections::HashMap<
qbz_audio::alsa_hardware_volume::StableAlsaRouteKey,
qbz_audio::alsa_hardware_volume::AlsaMixerControlId,
>,
/// Full read-only inventory for the active direct route, including
/// rejected controls and their typed reasons.
pub audio_alsa_hardware_volume_probe:
Option<qbz_audio::alsa_hardware_volume::HardwareVolumeProbe>,
pub audio_normalization_enabled: bool,
pub audio_normalization_target_lufs: f32,
pub audio_gapless_enabled: bool,
pub audio_pw_force_bitperfect: bool,
pub audio_stream_buffer_seconds: u8,
pub audio_streaming_only: bool,
// Graphics: saved settings
pub gfx_hardware_acceleration: bool,
pub gfx_force_x11: bool,
pub gfx_gdk_scale: Option<String>,
pub gfx_gdk_dpi_scale: Option<String>,
pub gfx_gsk_renderer: Option<String>,
// Graphics: runtime (what actually applied at startup)
pub runtime_using_fallback: bool,
pub runtime_is_wayland: bool,
pub runtime_has_nvidia: bool,
pub runtime_has_amd: bool,
pub runtime_has_intel: bool,
pub runtime_is_vm: bool,
pub runtime_hw_accel_enabled: bool,
pub runtime_force_x11_active: bool,
/// Human-readable GPU model name (driver-reported on Linux).
/// For hybrid laptops joins both vendors: "NVIDIA (...) + Intel (...)".
pub runtime_gpu_name: String,
/// Desktop environment string ($XDG_CURRENT_DESKTOP or fallbacks).
pub runtime_desktop_environment: String,
// Developer settings
pub dev_force_dmabuf: bool,
// Environment variables (what WebKit actually sees)
pub env_webkit_disable_dmabuf: Option<String>,
pub env_webkit_disable_compositing: Option<String>,
pub env_gdk_backend: Option<String>,
pub env_gsk_renderer: Option<String>,
pub env_libgl_always_software: Option<String>,
pub env_wayland_display: Option<String>,
pub env_xdg_session_type: Option<String>,
// App info
pub app_version: String,
}
/// Graphics runtime state the caller feeds in.
///
/// Mirrors the Tauri startup atomics (`get_graphics_startup_status()`), so the
/// builder stays framework-agnostic. The Tauri command maps its atomics into
/// this; the Slint bin computes it fresh via [`detect_graphics_runtime`].
pub struct GraphicsRuntime {
pub using_fallback: bool,
pub is_wayland: bool,
pub has_nvidia: bool,
pub has_amd: bool,
pub has_intel: bool,
pub is_vm: bool,
pub hardware_accel_enabled: bool,
pub force_x11_active: bool,
}
/// Inputs for [`runtime_diagnostics`]. The caller reads the three settings
/// structs from their stores, builds a [`GraphicsRuntime`], and passes a real
/// `app_version` string.
pub struct DiagnosticsInputs<'a> {
pub audio: &'a qbz_audio::settings::AudioSettings,
pub graphics: &'a crate::settings::graphics::GraphicsSettings,
pub developer: &'a crate::settings::developer::DeveloperSettings,
pub gfx: GraphicsRuntime,
pub app_version: &'a str,
}
/// Build the runtime diagnostics snapshot. Infallible.
///
/// Faithful port of `v2_get_runtime_diagnostics` (Tauri) reading from the
/// passed-in structs instead of `tauri::State`. The two `format!("{:?}", ..)`
/// Debug conversions for `audio_backend_type` and `audio_alsa_plugin` are kept
/// so the exported strings match Tauri exactly (stable enum variant names).
pub fn runtime_diagnostics(i: &DiagnosticsInputs<'_>) -> RuntimeDiagnostics {
let audio = i.audio;
let gfx = i.graphics;
let dev = i.developer;
let env_var = |name: &str| std::env::var(name).ok();
RuntimeDiagnostics {
audio_output_device: audio.output_device.clone(),
audio_backend_type: audio.backend_type.map(|b| format!("{:?}", b)),
audio_exclusive_mode: audio.exclusive_mode,
audio_dac_passthrough: audio.dac_passthrough,
audio_preferred_sample_rate: audio.preferred_sample_rate,
audio_alsa_plugin: audio.alsa_plugin.map(|p| format!("{:?}", p)),
audio_alsa_hardware_volume: audio.alsa_hardware_volume,
audio_alsa_hardware_volume_controls: audio.alsa_hardware_volume_controls.clone(),
audio_alsa_hardware_volume_probe: qbz_audio::alsa_direct::uses_alsa_direct_route(audio)
.then(|| {
qbz_audio::alsa_hardware_volume::enumerate_hardware_volume_controls(
audio
.output_device
.as_deref()
.expect("direct ALSA route has a device id"),
)
}),
audio_normalization_enabled: audio.normalization_enabled,
audio_normalization_target_lufs: audio.normalization_target_lufs,
audio_gapless_enabled: audio.gapless_enabled,
audio_pw_force_bitperfect: audio.pw_force_bitperfect,
audio_stream_buffer_seconds: audio.stream_buffer_seconds,
audio_streaming_only: audio.streaming_only,
gfx_hardware_acceleration: gfx.hardware_acceleration,
gfx_force_x11: gfx.force_x11,
gfx_gdk_scale: gfx.gdk_scale.clone(),
gfx_gdk_dpi_scale: gfx.gdk_dpi_scale.clone(),
gfx_gsk_renderer: gfx.gsk_renderer.clone(),
runtime_using_fallback: i.gfx.using_fallback,
runtime_is_wayland: i.gfx.is_wayland,
runtime_has_nvidia: i.gfx.has_nvidia,
runtime_has_amd: i.gfx.has_amd,
runtime_has_intel: i.gfx.has_intel,
runtime_is_vm: i.gfx.is_vm,
runtime_hw_accel_enabled: i.gfx.hardware_accel_enabled,
runtime_force_x11_active: i.gfx.force_x11_active,
runtime_gpu_name: crate::graphics_autoconfig::detect_gpu_name(
i.gfx.has_nvidia,
i.gfx.has_amd,
i.gfx.has_intel,
),
runtime_desktop_environment: std::env::var("XDG_CURRENT_DESKTOP")
.or_else(|_| std::env::var("XDG_SESSION_DESKTOP"))
.or_else(|_| std::env::var("DESKTOP_SESSION"))
.unwrap_or_else(|_| "Unknown".to_string()),
dev_force_dmabuf: dev.force_dmabuf,
env_webkit_disable_dmabuf: env_var("WEBKIT_DISABLE_DMABUF_RENDERER"),
env_webkit_disable_compositing: env_var("WEBKIT_DISABLE_COMPOSITING_MODE"),
env_gdk_backend: env_var("GDK_BACKEND"),
env_gsk_renderer: env_var("GSK_RENDERER"),
env_libgl_always_software: env_var("LIBGL_ALWAYS_SOFTWARE"),
env_wayland_display: env_var("WAYLAND_DISPLAY"),
env_xdg_session_type: env_var("XDG_SESSION_TYPE"),
app_version: i.app_version.to_string(),
}
}
/// Compute a [`GraphicsRuntime`] for the headless/Slint path.
///
/// Runs [`crate::graphics_autoconfig::detect_environment`] (pure /proc + /sys +
/// env detection) and maps it. `hardware_accel_enabled` reflects the saved
/// graphics setting; `force_x11_active` is `false` (the Slint bin has no
/// force-x11 path — it renders via winit/wgpu, not GDK); `using_fallback` is
/// passed in by the caller (e.g. set when the graphics store failed to open).
pub fn detect_graphics_runtime(
saved: &crate::settings::graphics::GraphicsSettings,
using_fallback: bool,
) -> GraphicsRuntime {
let env = crate::graphics_autoconfig::detect_environment();
GraphicsRuntime {
using_fallback,
is_wayland: env.display_server == "Wayland",
has_nvidia: env.gpu_nvidia,
has_amd: env.gpu_amd,
has_intel: env.gpu_intel,
is_vm: env.is_vm,
hardware_accel_enabled: saved.hardware_acceleration,
force_x11_active: false,
}
}
// ==================== System Info ====================
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemInfo {
pub os: String,
pub arch: String,
pub kernel_version: Option<String>,
pub distro_id: Option<String>,
pub distro_version_id: Option<String>,
pub distro_pretty_name: Option<String>,
pub install_method: String,
pub flatpak_runtime: Option<String>,
pub flatpak_runtime_version: Option<String>,
pub webkit2gtk_version: Option<String>,
pub gtk_version: Option<String>,
pub glibc_version: Option<String>,
pub alsa_version: Option<String>,
pub pipewire_version: Option<String>,
pub pulseaudio_version: Option<String>,
}
/// Parse /etc/os-release (or the Flatpak host equivalent) into key/value pairs.
fn read_os_release() -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
// Try Flatpak-exposed host file first, then the normal path.
let candidates = ["/run/host/os-release", "/etc/os-release"];
for path in candidates {
if let Ok(text) = std::fs::read_to_string(path) {
for line in text.lines() {
if let Some(idx) = line.find('=') {
let key = line[..idx].trim().to_string();
let mut value = line[idx + 1..].trim().to_string();
if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
value = value[1..value.len() - 1].to_string();
}
map.insert(key, value);
}
}
if !map.is_empty() {
return map;
}
}
}
map
}
fn detect_kernel_version() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|s| s.trim().to_string())
}
fn detect_install_method() -> (String, Option<String>, Option<String>) {
// (method, flatpak_runtime, flatpak_runtime_version)
if std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists() {
let mut runtime = None;
let mut runtime_version = None;
if let Ok(text) = std::fs::read_to_string("/.flatpak-info") {
for line in text.lines() {
if let Some(value) = line.strip_prefix("runtime=") {
let v = value.trim();
if let Some(slash) = v.rfind('/') {
runtime = Some(v[..slash].to_string());
runtime_version = Some(v[slash + 1..].to_string());
} else {
runtime = Some(v.to_string());
}
}
}
}
return ("flatpak".to_string(), runtime, runtime_version);
}
if std::env::var("SNAP").is_ok() {
return ("snap".to_string(), None, None);
}
if std::env::var("APPIMAGE").is_ok() {
return ("appimage".to_string(), None, None);
}
if cfg!(debug_assertions) {
return ("dev".to_string(), None, None);
}
("native".to_string(), None, None)
}
/// Extract the best-available version string from the filename of a shared
/// library loaded by the current process. Looks for patterns like
/// `libfoo.so.0.15.7` → `0.15.7`, or `libfoo.so.2` → `2`.
/// Returns `None` if the library isn't mapped.
fn detect_loaded_lib_version(lib_name_stem: &str) -> Option<String> {
let maps = std::fs::read_to_string("/proc/self/maps").ok()?;
let mut best: Option<String> = None;
for line in maps.lines() {
// Last column is the path (may contain spaces, very rare).
let path = line.splitn(6, ' ').nth(5).unwrap_or("").trim();
if path.is_empty() {
continue;
}
let filename = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if !filename.starts_with(lib_name_stem) {
continue;
}
// filename example: "libwebkit2gtk-4.1.so.0.15.7"
// Strip the "lib_name_stem.so" prefix and leading dot.
let tail = match filename.split_once(".so") {
Some((_, rest)) => rest.trim_start_matches('.'),
None => continue,
};
if tail.is_empty() {
continue;
}
// Resolve symlink target if possible — often the real file carries
// a fuller version than the SONAME alias.
if let Ok(real) = std::fs::canonicalize(path) {
if let Some(real_name) = real.file_name().and_then(|s| s.to_str()) {
if let Some((_, rest)) = real_name.split_once(".so") {
let real_tail = rest.trim_start_matches('.');
if !real_tail.is_empty() {
best = Some(real_tail.to_string());
continue;
}
}
}
}
best.get_or_insert_with(|| tail.to_string());
}
best
}
/// Build the system info snapshot. Pure + infallible.
///
/// Faithful port of `v2_get_system_info` (Tauri) minus the Ok-wrap.
pub fn system_info() -> SystemInfo {
let os = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();
let (install_method, flatpak_runtime, flatpak_runtime_version) = detect_install_method();
let osr = read_os_release();
SystemInfo {
os,
arch,
kernel_version: detect_kernel_version(),
distro_id: osr.get("ID").cloned(),
distro_version_id: osr.get("VERSION_ID").cloned(),
distro_pretty_name: osr
.get("PRETTY_NAME")
.cloned()
.or_else(|| osr.get("NAME").cloned()),
install_method,
flatpak_runtime,
flatpak_runtime_version,
// Runtime shared-library versions, parsed from /proc/self/maps.
webkit2gtk_version: detect_loaded_lib_version("libwebkit2gtk-4.1"),
gtk_version: detect_loaded_lib_version("libgtk-3")
.or_else(|| detect_loaded_lib_version("libgtk-4")),
glibc_version: detect_loaded_lib_version("libc"),
alsa_version: detect_loaded_lib_version("libasound"),
pipewire_version: detect_loaded_lib_version("libpipewire-0.3"),
pulseaudio_version: detect_loaded_lib_version("libpulse"),
}
}