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:
8 files changed
+248
-2
No files matched your search
@@ -166,3 +166,166 @@ pub fn v2_get_runtime_diagnostics(
|
|||||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1361,6 +1361,7 @@ pub fn run() {
|
|||||||
commands_v2::v2_set_visualizer_enabled,
|
commands_v2::v2_set_visualizer_enabled,
|
||||||
commands_v2::v2_get_developer_settings,
|
commands_v2::v2_get_developer_settings,
|
||||||
commands_v2::v2_get_runtime_diagnostics,
|
commands_v2::v2_get_runtime_diagnostics,
|
||||||
|
commands_v2::v2_get_system_info,
|
||||||
commands_v2::v2_set_developer_force_dmabuf,
|
commands_v2::v2_set_developer_force_dmabuf,
|
||||||
commands_v2::v2_get_graphics_settings,
|
commands_v2::v2_get_graphics_settings,
|
||||||
commands_v2::v2_get_graphics_startup_status,
|
commands_v2::v2_get_graphics_startup_status,
|
||||||
|
|||||||
@@ -58,6 +58,24 @@
|
|||||||
status: 'match' | 'mismatch' | 'info';
|
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 {
|
interface PlaybackSnapshot {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
volumePercent: number;
|
volumePercent: number;
|
||||||
@@ -101,6 +119,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let diagnostics = $state<RuntimeDiagnostics | null>(null);
|
let diagnostics = $state<RuntimeDiagnostics | null>(null);
|
||||||
|
let systemInfo = $state<SystemInfo | null>(null);
|
||||||
let playback = $state<PlaybackSnapshot | null>(null);
|
let playback = $state<PlaybackSnapshot | null>(null);
|
||||||
let qconnect = $state<QconnectDiag | null>(null);
|
let qconnect = $state<QconnectDiag | null>(null);
|
||||||
let castScan = $state<CastScanResult | null>(null);
|
let castScan = $state<CastScanResult | null>(null);
|
||||||
@@ -116,6 +135,7 @@
|
|||||||
let playbackOpen = $state(true);
|
let playbackOpen = $state(true);
|
||||||
let qconnectOpen = $state(true);
|
let qconnectOpen = $state(true);
|
||||||
let castOpen = $state(true);
|
let castOpen = $state(true);
|
||||||
|
let systemOpen = $state(true);
|
||||||
|
|
||||||
const CAST_SCAN_DURATION_MS = 10000;
|
const CAST_SCAN_DURATION_MS = 10000;
|
||||||
|
|
||||||
@@ -311,8 +331,9 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const [diagRes, qcRes] = await Promise.allSettled([
|
const [diagRes, sysRes, qcRes] = await Promise.allSettled([
|
||||||
invoke<RuntimeDiagnostics>('v2_get_runtime_diagnostics'),
|
invoke<RuntimeDiagnostics>('v2_get_runtime_diagnostics'),
|
||||||
|
invoke<SystemInfo>('v2_get_system_info'),
|
||||||
snapshotQconnect(),
|
snapshotQconnect(),
|
||||||
]);
|
]);
|
||||||
if (diagRes.status === 'fulfilled') {
|
if (diagRes.status === 'fulfilled') {
|
||||||
@@ -320,6 +341,9 @@
|
|||||||
} else {
|
} else {
|
||||||
error = String(diagRes.reason);
|
error = String(diagRes.reason);
|
||||||
}
|
}
|
||||||
|
if (sysRes.status === 'fulfilled') {
|
||||||
|
systemInfo = sysRes.value;
|
||||||
|
}
|
||||||
if (qcRes.status === 'fulfilled') {
|
if (qcRes.status === 'fulfilled') {
|
||||||
qconnect = qcRes.value;
|
qconnect = qcRes.value;
|
||||||
}
|
}
|
||||||
@@ -334,6 +358,7 @@
|
|||||||
try {
|
try {
|
||||||
const exportData = {
|
const exportData = {
|
||||||
...diagnostics,
|
...diagnostics,
|
||||||
|
systemInfo,
|
||||||
playback,
|
playback,
|
||||||
qconnect,
|
qconnect,
|
||||||
castScan,
|
castScan,
|
||||||
@@ -345,7 +370,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(
|
||||||
JSON.stringify({ ...diagnostics, playback, qconnect, castScan }, null, 2)
|
JSON.stringify({ ...diagnostics, systemInfo, playback, qconnect, castScan }, null, 2)
|
||||||
);
|
);
|
||||||
copied = true;
|
copied = true;
|
||||||
setTimeout(() => { copied = false; }, 1500);
|
setTimeout(() => { copied = false; }, 1500);
|
||||||
@@ -370,6 +395,30 @@
|
|||||||
// (intentional) don't leave any subscriptions behind
|
// (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[] {
|
function playbackRows(p: PlaybackSnapshot): DiagRow[] {
|
||||||
return [
|
return [
|
||||||
{ label: 'Playing', saved: '—', runtime: bool(p.isPlaying), status: 'info' },
|
{ label: 'Playing', saved: '—', runtime: bool(p.isPlaying), status: 'info' },
|
||||||
@@ -450,6 +499,34 @@
|
|||||||
{#if diagnostics}
|
{#if diagnostics}
|
||||||
<div class="diag-version">QBZ v{diagnostics.appVersion}</div>
|
<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 -->
|
<!-- Playback Section -->
|
||||||
{#if playback}
|
{#if playback}
|
||||||
<button class="section-toggle" onclick={() => playbackOpen = !playbackOpen}>
|
<button class="section-toggle" onclick={() => playbackOpen = !playbackOpen}>
|
||||||
|
|||||||
@@ -1373,6 +1373,7 @@
|
|||||||
"sectionGraphics": "Grafik & Rendering",
|
"sectionGraphics": "Grafik & Rendering",
|
||||||
"sectionEnv": "Umgebungsvariablen",
|
"sectionEnv": "Umgebungsvariablen",
|
||||||
"sectionPlayback": "Wiedergabe",
|
"sectionPlayback": "Wiedergabe",
|
||||||
|
"sectionSystem": "System",
|
||||||
"sectionQconnect": "Qobuz Connect",
|
"sectionQconnect": "Qobuz Connect",
|
||||||
"sectionCast": "Cast-Erkennung",
|
"sectionCast": "Cast-Erkennung",
|
||||||
"castScan": "10s scannen",
|
"castScan": "10s scannen",
|
||||||
|
|||||||
@@ -1379,6 +1379,7 @@
|
|||||||
"sectionGraphics": "Graphics & Rendering",
|
"sectionGraphics": "Graphics & Rendering",
|
||||||
"sectionEnv": "Environment Variables",
|
"sectionEnv": "Environment Variables",
|
||||||
"sectionPlayback": "Playback",
|
"sectionPlayback": "Playback",
|
||||||
|
"sectionSystem": "System",
|
||||||
"sectionQconnect": "Qobuz Connect",
|
"sectionQconnect": "Qobuz Connect",
|
||||||
"sectionCast": "Cast Discovery",
|
"sectionCast": "Cast Discovery",
|
||||||
"castScan": "Run 10s scan",
|
"castScan": "Run 10s scan",
|
||||||
|
|||||||
@@ -1379,6 +1379,7 @@
|
|||||||
"sectionGraphics": "Gráficos y renderizado",
|
"sectionGraphics": "Gráficos y renderizado",
|
||||||
"sectionEnv": "Variables de entorno",
|
"sectionEnv": "Variables de entorno",
|
||||||
"sectionPlayback": "Reproducción",
|
"sectionPlayback": "Reproducción",
|
||||||
|
"sectionSystem": "Sistema",
|
||||||
"sectionQconnect": "Qobuz Connect",
|
"sectionQconnect": "Qobuz Connect",
|
||||||
"sectionCast": "Descubrimiento Cast",
|
"sectionCast": "Descubrimiento Cast",
|
||||||
"castScan": "Escanear 10s",
|
"castScan": "Escanear 10s",
|
||||||
|
|||||||
@@ -1373,6 +1373,7 @@
|
|||||||
"sectionGraphics": "Graphiques et rendu",
|
"sectionGraphics": "Graphiques et rendu",
|
||||||
"sectionEnv": "Variables d'environnement",
|
"sectionEnv": "Variables d'environnement",
|
||||||
"sectionPlayback": "Lecture",
|
"sectionPlayback": "Lecture",
|
||||||
|
"sectionSystem": "Système",
|
||||||
"sectionQconnect": "Qobuz Connect",
|
"sectionQconnect": "Qobuz Connect",
|
||||||
"sectionCast": "Découverte Cast",
|
"sectionCast": "Découverte Cast",
|
||||||
"castScan": "Scan 10s",
|
"castScan": "Scan 10s",
|
||||||
|
|||||||
@@ -1373,6 +1373,7 @@
|
|||||||
"sectionGraphics": "Gráficos e renderização",
|
"sectionGraphics": "Gráficos e renderização",
|
||||||
"sectionEnv": "Variáveis de ambiente",
|
"sectionEnv": "Variáveis de ambiente",
|
||||||
"sectionPlayback": "Reprodução",
|
"sectionPlayback": "Reprodução",
|
||||||
|
"sectionSystem": "Sistema",
|
||||||
"sectionQconnect": "Qobuz Connect",
|
"sectionQconnect": "Qobuz Connect",
|
||||||
"sectionCast": "Descoberta Cast",
|
"sectionCast": "Descoberta Cast",
|
||||||
"castScan": "Escanear 10s",
|
"castScan": "Escanear 10s",
|
||||||
|
|||||||
Reference in new issue
Block a user