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
This commit is contained in:
vicrodh committed 2026-04-16 11:14:26 -06:00
1 parent 85057589e6
commit d5e3fff45c
8 files changed
+248 -2

No files matched your search

+163
View File
@@ -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<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
}
#[tauri::command]
pub fn v2_get_system_info() -> Result<SystemInfo, RuntimeError> {
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"),
})
}
+1
View File
@@ -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,
+79 -2
View File
@@ -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<RuntimeDiagnostics | null>(null);
let systemInfo = $state<SystemInfo | null>(null);
let playback = $state<PlaybackSnapshot | null>(null);
let qconnect = $state<QconnectDiag | null>(null);
let castScan = $state<CastScanResult | null>(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<RuntimeDiagnostics>('v2_get_runtime_diagnostics'),
invoke<SystemInfo>('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}
<div class="diag-version">QBZ v{diagnostics.appVersion}</div>
<!-- System Info Section -->
{#if systemInfo}
<button class="section-toggle" onclick={() => systemOpen = !systemOpen}>
{#if systemOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{$t('settings.developer.diagnostics.sectionSystem')}
</button>
{#if systemOpen}
<table class="diag-table">
<thead>
<tr>
<th>{$t('settings.developer.diagnostics.colSetting')}</th>
<th colspan="2">{$t('settings.developer.diagnostics.colRuntime')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each systemRows(systemInfo) as row (row.label)}
<tr>
<td class="label-cell">{row.label}</td>
<td class="value-cell" colspan="2">{row.runtime}</td>
<td class="status-cell info">·</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
<!-- Playback Section -->
{#if playback}
<button class="section-toggle" onclick={() => playbackOpen = !playbackOpen}>
+1
View File
@@ -1373,6 +1373,7 @@
"sectionGraphics": "Grafik & Rendering",
"sectionEnv": "Umgebungsvariablen",
"sectionPlayback": "Wiedergabe",
"sectionSystem": "System",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Cast-Erkennung",
"castScan": "10s scannen",
+1
View File
@@ -1379,6 +1379,7 @@
"sectionGraphics": "Graphics & Rendering",
"sectionEnv": "Environment Variables",
"sectionPlayback": "Playback",
"sectionSystem": "System",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Cast Discovery",
"castScan": "Run 10s scan",
+1
View File
@@ -1379,6 +1379,7 @@
"sectionGraphics": "Gráficos y renderizado",
"sectionEnv": "Variables de entorno",
"sectionPlayback": "Reproducción",
"sectionSystem": "Sistema",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Descubrimiento Cast",
"castScan": "Escanear 10s",
+1
View File
@@ -1373,6 +1373,7 @@
"sectionGraphics": "Graphiques et rendu",
"sectionEnv": "Variables d'environnement",
"sectionPlayback": "Lecture",
"sectionSystem": "Système",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Découverte Cast",
"castScan": "Scan 10s",
+1
View File
@@ -1373,6 +1373,7 @@
"sectionGraphics": "Gráficos e renderização",
"sectionEnv": "Variáveis de ambiente",
"sectionPlayback": "Reprodução",
"sectionSystem": "Sistema",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Descoberta Cast",
"castScan": "Escanear 10s",