From d5e3fff45cfe83c1efdf1c6511d59d3ea063ddd7 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:14:26 -0600 Subject: [PATCH] feat(diagnostics): add OS / distro / install method / deps section New v2_get_system_info command and matching UI section in the System Diagnostics panel. What it reports: - os (linux/macos/windows) - arch (x86_64, aarch64) - kernel_version from /proc/sys/kernel/osrelease - Linux distro: id, version_id, pretty_name from /etc/os-release (falls back to /run/host/os-release so it works inside Flatpak) - install_method: flatpak / snap / appimage / dev / native, detected via FLATPAK_ID + /.flatpak-info, SNAP, APPIMAGE env vars, and debug_assertions as the last fallback - flatpak_runtime + version: parsed from /.flatpak-info - loaded library versions (from /proc/self/maps, symlink-resolved): webkit2gtk-4.1, gtk-3 (or 4), glibc, alsa, pipewire, pulseaudio All info is derived from local files/env; no sensitive data, no IDs, no hashes. Included in the clipboard export. cc --- src-tauri/src/commands_v2/diagnostics.rs | 163 +++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/DiagnosticsPanel.svelte | 81 +++++++++- src/lib/i18n/locales/de.json | 1 + src/lib/i18n/locales/en.json | 1 + src/lib/i18n/locales/es.json | 1 + src/lib/i18n/locales/fr.json | 1 + src/lib/i18n/locales/pt.json | 1 + 8 files changed, 248 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands_v2/diagnostics.rs b/src-tauri/src/commands_v2/diagnostics.rs index 93a21e589..429a953e3 100644 --- a/src-tauri/src/commands_v2/diagnostics.rs +++ b/src-tauri/src/commands_v2/diagnostics.rs @@ -166,3 +166,166 @@ pub fn v2_get_runtime_diagnostics( app_version: env!("CARGO_PKG_VERSION").to_string(), }) } + +// ==================== System Info ==================== + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemInfo { + pub os: String, + pub arch: String, + pub kernel_version: Option, + pub distro_id: Option, + pub distro_version_id: Option, + pub distro_pretty_name: Option, + pub install_method: String, + pub flatpak_runtime: Option, + pub flatpak_runtime_version: Option, + pub webkit2gtk_version: Option, + pub gtk_version: Option, + pub glibc_version: Option, + pub alsa_version: Option, + pub pipewire_version: Option, + pub pulseaudio_version: Option, +} + +/// Parse /etc/os-release (or the Flatpak host equivalent) into key/value pairs. +fn read_os_release() -> std::collections::HashMap { + 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 { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|s| s.trim().to_string()) +} + +fn detect_install_method() -> (String, Option, Option) { + // (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 { + let maps = std::fs::read_to_string("/proc/self/maps").ok()?; + let mut best: Option = 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 +} + +#[tauri::command] +pub fn v2_get_system_info() -> Result { + 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(); + + Ok(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"), + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7abe365f8..3bb26bf42 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1361,6 +1361,7 @@ pub fn run() { commands_v2::v2_set_visualizer_enabled, commands_v2::v2_get_developer_settings, commands_v2::v2_get_runtime_diagnostics, + commands_v2::v2_get_system_info, commands_v2::v2_set_developer_force_dmabuf, commands_v2::v2_get_graphics_settings, commands_v2::v2_get_graphics_startup_status, diff --git a/src/lib/components/DiagnosticsPanel.svelte b/src/lib/components/DiagnosticsPanel.svelte index da4f84cbb..f718ce514 100644 --- a/src/lib/components/DiagnosticsPanel.svelte +++ b/src/lib/components/DiagnosticsPanel.svelte @@ -58,6 +58,24 @@ status: 'match' | 'mismatch' | 'info'; } + interface SystemInfo { + os: string; + arch: string; + kernelVersion: string | null; + distroId: string | null; + distroVersionId: string | null; + distroPrettyName: string | null; + installMethod: string; + flatpakRuntime: string | null; + flatpakRuntimeVersion: string | null; + webkit2gtkVersion: string | null; + gtkVersion: string | null; + glibcVersion: string | null; + alsaVersion: string | null; + pipewireVersion: string | null; + pulseaudioVersion: string | null; + } + interface PlaybackSnapshot { isPlaying: boolean; volumePercent: number; @@ -101,6 +119,7 @@ } let diagnostics = $state(null); + let systemInfo = $state(null); let playback = $state(null); let qconnect = $state(null); let castScan = $state(null); @@ -116,6 +135,7 @@ let playbackOpen = $state(true); let qconnectOpen = $state(true); let castOpen = $state(true); + let systemOpen = $state(true); const CAST_SCAN_DURATION_MS = 10000; @@ -311,8 +331,9 @@ loading = true; error = null; try { - const [diagRes, qcRes] = await Promise.allSettled([ + const [diagRes, sysRes, qcRes] = await Promise.allSettled([ invoke('v2_get_runtime_diagnostics'), + invoke('v2_get_system_info'), snapshotQconnect(), ]); if (diagRes.status === 'fulfilled') { @@ -320,6 +341,9 @@ } else { error = String(diagRes.reason); } + if (sysRes.status === 'fulfilled') { + systemInfo = sysRes.value; + } if (qcRes.status === 'fulfilled') { qconnect = qcRes.value; } @@ -334,6 +358,7 @@ try { const exportData = { ...diagnostics, + systemInfo, playback, qconnect, castScan, @@ -345,7 +370,7 @@ } catch { try { await navigator.clipboard.writeText( - JSON.stringify({ ...diagnostics, playback, qconnect, castScan }, null, 2) + JSON.stringify({ ...diagnostics, systemInfo, playback, qconnect, castScan }, null, 2) ); copied = true; setTimeout(() => { copied = false; }, 1500); @@ -370,6 +395,30 @@ // (intentional) don't leave any subscriptions behind }); + function systemRows(s: SystemInfo): DiagRow[] { + const rows: DiagRow[] = [ + { label: 'OS', saved: '—', runtime: s.os, status: 'info' }, + { label: 'Arch', saved: '—', runtime: s.arch, status: 'info' }, + { label: 'Kernel', saved: '—', runtime: str(s.kernelVersion), status: 'info' }, + { label: 'Distro', saved: '—', runtime: str(s.distroPrettyName), status: 'info' }, + { label: 'Distro ID', saved: '—', runtime: str(s.distroId), status: 'info' }, + { label: 'Distro Version', saved: '—', runtime: str(s.distroVersionId), status: 'info' }, + { label: 'Install Method', saved: '—', runtime: s.installMethod, status: 'info' }, + ]; + if (s.flatpakRuntime) { + rows.push({ label: 'Flatpak Runtime', saved: '—', runtime: `${s.flatpakRuntime} ${str(s.flatpakRuntimeVersion)}`, status: 'info' }); + } + rows.push( + { label: 'WebKit2GTK', saved: '—', runtime: str(s.webkit2gtkVersion), status: 'info' }, + { label: 'GTK', saved: '—', runtime: str(s.gtkVersion), status: 'info' }, + { label: 'glibc', saved: '—', runtime: str(s.glibcVersion), status: 'info' }, + { label: 'ALSA', saved: '—', runtime: str(s.alsaVersion), status: 'info' }, + { label: 'PipeWire', saved: '—', runtime: str(s.pipewireVersion), status: 'info' }, + { label: 'PulseAudio', saved: '—', runtime: str(s.pulseaudioVersion), status: 'info' }, + ); + return rows; + } + function playbackRows(p: PlaybackSnapshot): DiagRow[] { return [ { label: 'Playing', saved: '—', runtime: bool(p.isPlaying), status: 'info' }, @@ -450,6 +499,34 @@ {#if diagnostics}
QBZ v{diagnostics.appVersion}
+ + {#if systemInfo} + + {#if systemOpen} + + + + + + + + + + {#each systemRows(systemInfo) as row (row.label)} + + + + + + {/each} + +
{$t('settings.developer.diagnostics.colSetting')}{$t('settings.developer.diagnostics.colRuntime')}
{row.label}{row.runtime}·
+ {/if} + {/if} + {#if playback}