feat(graphics): smart GPU preference with boot watchdog and crash recovery

Solves the long-running performance complaints (especially Optimus
laptops where the dGPU silently hijacks WebKit). The end-to-end shape:

- graphics_detection enumerates physical GPUs from /sys/class/drm and
  cross-references EGL vendor JSONs under /usr/share/glvnd/egl_vendor.d
  to expose only stacks the running process can actually use. Works
  inside Flatpak/Snap because /sys is still mounted read-only and the
  sandbox-visible EGL vendor JSONs are the right thing to read.
- preferred_gpu column in graphics_settings.db (string: auto, integrated,
  discrete, software, or a specific PCI slot id). Migrated with
  ALTER TABLE ... DEFAULT 'auto'.
- env_vars_for_preferred_gpu returns Set/Unset actions applied at startup
  before WebKit init. Integrated/AMD path explicitly unsets
  __NV_PRIME_RENDER_OFFLOAD so KDE/Optimus sessions that pre-set it to 1
  no longer override the EGL pin and yank rendering back onto NVIDIA.
- Auto resolver: on hybrid systems (iGPU + dGPU both usable), Auto pins
  to the iGPU. Single-GPU and no-GPU systems remain no-op. The dropdown
  surfaces what Auto resolved to ("Auto (Intel Graphics)") so the user
  sees the decision without picking explicitly.
- boot_watchdog writes a pending.json before WebKit init listing the
  risky knobs in effect. Frontend calls v2_mark_boot_succeeded after
  first paint to clear it and promote to last-good.json. If the next
  launch sees pending without a success ack, it blames matching fields,
  reverts them in-DB, and increments per-knob failure counters; two
  consecutive failures lock the knob until the user clears it.
- Settings > Graphics gets a Rendering GPU dropdown (between HW accel
  and DMA-BUF) and a crash-recovery banner that lets the user re-enable
  any auto-disabled knob. Restart-required affordance shared with the
  other risky toggles.
- i18n keys for the dropdown, banner copy, and autoWithTarget label
  across all five locales.

cc
This commit is contained in:
vicrodh committed 2026-05-14 00:36:52 -06:00
1 parent 77754e168a
commit b2960e8029
14 files changed
+1391 -5

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
//! Boot watchdog for graphics settings that can crash WebKit before it
//! paints a single frame (HW acceleration, DMA-BUF, preferred-GPU
//! selection that picks an unreachable stack).
//!
//! Flow:
//! 1. `before_webkit_init()` is called at startup with the risky
//! settings of the current boot. It writes a `pending.json` marker
//! to `<data_dir>/qbz/boot_state/` recording which settings were
//! attempted.
//! 2. After WebKit boots and the frontend signals first-paint (via
//! `v2_mark_boot_succeeded`), `mark_boot_succeeded()` removes the
//! pending marker and writes `last-good.json` carrying the
//! successful settings.
//! 3. On the NEXT boot, `before_webkit_init` first calls
//! `check_previous_boot()`. If a pending marker exists without a
//! matching success, the previous boot crashed during graphics
//! init — we auto-revert the offending setting(s), record a
//! crash recovery flag, and only THEN write the new pending
//! marker for the current boot.
//!
//! Two consecutive crashes with the same setting promote to "lockout"
//! state: the setting stays disabled until the user explicitly clears
//! the flag from Settings. Prevents an infinite retry loop.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const STATE_DIR: &str = "boot_state";
const PENDING_FILE: &str = "pending.json";
const LAST_GOOD_FILE: &str = "last-good.json";
const CRASH_FLAGS_FILE: &str = "crash-flags.json";
/// Snapshot of the risky settings attempted in a boot. Compared against
/// the previous boot's snapshot to decide which setting "caused" a crash.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BootAttempt {
pub hardware_acceleration: bool,
pub force_dmabuf: bool,
pub preferred_gpu: String,
pub force_x11: bool,
}
/// Sticky flags written when a setting was auto-reverted after a crash.
/// The UI reads these to render the recovery banner. Frontend can also
/// clear them via `v2_clear_crash_recovery_flag`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CrashFlags {
/// Last boot crashed with hardware_acceleration ON; we reverted it.
pub hardware_acceleration_disabled: bool,
pub hardware_acceleration_consecutive_failures: u8,
/// Same for force_dmabuf.
pub force_dmabuf_disabled: bool,
pub force_dmabuf_consecutive_failures: u8,
/// Same for preferred_gpu (reset to "auto").
pub preferred_gpu_disabled: bool,
pub preferred_gpu_consecutive_failures: u8,
/// Lockout — set when consecutive_failures >= 2 for a given knob.
/// The runtime continues to revert; the UI shows a stronger
/// "disabled due to repeated crashes" message and gates re-enable
/// behind an explicit clear action.
pub hardware_acceleration_locked: bool,
pub force_dmabuf_locked: bool,
pub preferred_gpu_locked: bool,
}
/// Result of `before_webkit_init`. The caller (main.rs) replaces its
/// own variables for hardware_accel / force_dmabuf / preferred_gpu
/// with these values before proceeding to apply env vars.
#[derive(Debug, Clone)]
pub struct WatchdogResolution {
pub hardware_acceleration: bool,
pub force_dmabuf: bool,
pub preferred_gpu: String,
/// Reverted-in-this-boot flags so the UI knows which setting was
/// just rolled back. Persisted in CrashFlags as well.
pub recovery_messages: Vec<String>,
}
fn state_dir() -> Option<PathBuf> {
let base = dirs::data_dir()?.join("qbz").join(STATE_DIR);
fs::create_dir_all(&base).ok()?;
Some(base)
}
fn read_pending() -> Option<BootAttempt> {
let path = state_dir()?.join(PENDING_FILE);
let text = fs::read_to_string(&path).ok()?;
serde_json::from_str(&text).ok()
}
fn write_pending(attempt: &BootAttempt) {
let Some(dir) = state_dir() else {
return;
};
if let Ok(text) = serde_json::to_string_pretty(attempt) {
let _ = fs::write(dir.join(PENDING_FILE), text);
}
}
fn clear_pending() {
if let Some(dir) = state_dir() {
let _ = fs::remove_file(dir.join(PENDING_FILE));
}
}
fn write_last_good(attempt: &BootAttempt) {
let Some(dir) = state_dir() else {
return;
};
if let Ok(text) = serde_json::to_string_pretty(attempt) {
let _ = fs::write(dir.join(LAST_GOOD_FILE), text);
}
}
fn read_crash_flags() -> CrashFlags {
let Some(dir) = state_dir() else {
return CrashFlags::default();
};
let path = dir.join(CRASH_FLAGS_FILE);
let Ok(text) = fs::read_to_string(&path) else {
return CrashFlags::default();
};
serde_json::from_str(&text).unwrap_or_default()
}
fn write_crash_flags(flags: &CrashFlags) {
let Some(dir) = state_dir() else {
return;
};
if let Ok(text) = serde_json::to_string_pretty(flags) {
let _ = fs::write(dir.join(CRASH_FLAGS_FILE), text);
}
}
/// Called at startup with the settings the current boot WOULD apply if
/// nothing crashed. Returns the resolved settings to actually use —
/// usually the same input, but with risky knobs reverted if the
/// previous boot left a pending marker.
///
/// Also writes the new pending marker for THIS boot so a crash before
/// the frontend signals success is detectable on the next launch.
pub fn before_webkit_init(intended: BootAttempt) -> WatchdogResolution {
let mut flags = read_crash_flags();
let mut resolved = intended.clone();
let mut messages: Vec<String> = Vec::new();
// If a previous boot left a pending marker, it crashed before
// first paint. Compare what it tried against what we'd try now,
// and revert the field(s) that look like the cause.
if let Some(prev) = read_pending() {
// Hardware acceleration: if previous tried ON and we're also
// trying ON, blame this knob and revert.
if prev.hardware_acceleration && resolved.hardware_acceleration {
flags.hardware_acceleration_consecutive_failures =
flags.hardware_acceleration_consecutive_failures.saturating_add(1);
resolved.hardware_acceleration = false;
flags.hardware_acceleration_disabled = true;
if flags.hardware_acceleration_consecutive_failures >= 2 {
flags.hardware_acceleration_locked = true;
}
messages.push(
"Hardware acceleration was disabled because the previous launch crashed during \
graphics init."
.to_string(),
);
}
// Force DMA-BUF: same logic.
if prev.force_dmabuf && resolved.force_dmabuf {
flags.force_dmabuf_consecutive_failures =
flags.force_dmabuf_consecutive_failures.saturating_add(1);
resolved.force_dmabuf = false;
flags.force_dmabuf_disabled = true;
if flags.force_dmabuf_consecutive_failures >= 2 {
flags.force_dmabuf_locked = true;
}
messages.push(
"DMA-BUF renderer was disabled because the previous launch crashed during \
graphics init."
.to_string(),
);
}
// Preferred GPU: only revert when the SAME non-auto choice
// was attempted. Switching from one explicit GPU to another
// shouldn't inherit a crash flag from the prior choice.
if prev.preferred_gpu == resolved.preferred_gpu && resolved.preferred_gpu != "auto" {
flags.preferred_gpu_consecutive_failures =
flags.preferred_gpu_consecutive_failures.saturating_add(1);
resolved.preferred_gpu = "auto".to_string();
flags.preferred_gpu_disabled = true;
if flags.preferred_gpu_consecutive_failures >= 2 {
flags.preferred_gpu_locked = true;
}
messages.push(format!(
"Rendering GPU preference was reset to Auto because pinning to {:?} crashed the \
previous launch.",
prev.preferred_gpu
));
}
// If a previous attempt is present but none of the three
// matched, the crash was caused by something else — leave the
// flags as-is. The pending marker still gets cleared below.
// Persist the revert decisions back to graphics_settings.db so
// the user sees the new state in the UI and the next boot
// doesn't try the same setting again.
persist_reverts_to_db(&resolved, &intended);
// Clear consecutive-failure counters for any knob that was
// attempted AND succeeded (we infer "succeeded" from "this
// boot's input matches last-good"). Handled in
// mark_boot_succeeded for paths that actually paint.
} else {
// No pending marker → previous boot either succeeded or never
// ran the watchdog. Reset disabled flags to non-sticky values
// so the UI doesn't keep showing a banner across launches
// where everything's fine. Locked flags STAY sticky — those
// require explicit user clear.
if !flags.hardware_acceleration_locked {
flags.hardware_acceleration_disabled = false;
flags.hardware_acceleration_consecutive_failures = 0;
}
if !flags.force_dmabuf_locked {
flags.force_dmabuf_disabled = false;
flags.force_dmabuf_consecutive_failures = 0;
}
if !flags.preferred_gpu_locked {
flags.preferred_gpu_disabled = false;
flags.preferred_gpu_consecutive_failures = 0;
}
}
write_crash_flags(&flags);
write_pending(&resolved);
WatchdogResolution {
hardware_acceleration: resolved.hardware_acceleration,
force_dmabuf: resolved.force_dmabuf,
preferred_gpu: resolved.preferred_gpu,
recovery_messages: messages,
}
}
/// Called from a Tauri command after the frontend signals first paint.
/// Removes the pending marker so the next launch doesn't think this
/// boot crashed; writes last-good as the new baseline; clears
/// consecutive-failure counters for whatever settings were active.
pub fn mark_boot_succeeded() {
// Read what we attempted in this boot — the pending file is the
// source of truth for "what was active when WebKit first paint
// happened".
let attempt = read_pending().unwrap_or_default();
clear_pending();
write_last_good(&attempt);
let mut flags = read_crash_flags();
// Successful paint with HW accel ON resets that knob's failure
// streak entirely (including lockout — the issue resolved).
if attempt.hardware_acceleration {
flags.hardware_acceleration_consecutive_failures = 0;
flags.hardware_acceleration_disabled = false;
flags.hardware_acceleration_locked = false;
}
if attempt.force_dmabuf {
flags.force_dmabuf_consecutive_failures = 0;
flags.force_dmabuf_disabled = false;
flags.force_dmabuf_locked = false;
}
if attempt.preferred_gpu != "auto" {
flags.preferred_gpu_consecutive_failures = 0;
flags.preferred_gpu_disabled = false;
flags.preferred_gpu_locked = false;
}
write_crash_flags(&flags);
}
/// Read-only view of the crash flags for the Settings UI.
pub fn get_crash_flags() -> CrashFlags {
read_crash_flags()
}
/// Clear a single crash recovery flag by name. Frontend uses this when
/// the user clicks "Try again" / "I want to re-enable this" on the
/// recovery banner. Unknown names are no-ops.
pub fn clear_crash_flag(flag: &str) {
let mut flags = read_crash_flags();
match flag {
"hardware_acceleration" => {
flags.hardware_acceleration_disabled = false;
flags.hardware_acceleration_consecutive_failures = 0;
flags.hardware_acceleration_locked = false;
}
"force_dmabuf" => {
flags.force_dmabuf_disabled = false;
flags.force_dmabuf_consecutive_failures = 0;
flags.force_dmabuf_locked = false;
}
"preferred_gpu" => {
flags.preferred_gpu_disabled = false;
flags.preferred_gpu_consecutive_failures = 0;
flags.preferred_gpu_locked = false;
}
_ => return,
}
write_crash_flags(&flags);
}
/// Best-effort write of reverted values back to graphics_settings.db
/// and developer_settings.db. Failure is non-fatal — the runtime
/// values for THIS boot are still the reverted ones (we pass them
/// back to main.rs via WatchdogResolution), and the next boot's read
/// will see the reverted DB rows too.
fn persist_reverts_to_db(resolved: &BootAttempt, intended: &BootAttempt) {
if resolved.hardware_acceleration != intended.hardware_acceleration {
if let Ok(store) = crate::config::graphics_settings::GraphicsSettingsStore::new() {
let _ = store.set_hardware_acceleration(resolved.hardware_acceleration);
}
}
if resolved.force_dmabuf != intended.force_dmabuf {
if let Ok(store) = crate::config::developer_settings::DeveloperSettingsStore::new() {
let _ = store.set_force_dmabuf(resolved.force_dmabuf);
}
}
if resolved.preferred_gpu != intended.preferred_gpu {
if let Ok(store) = crate::config::graphics_settings::GraphicsSettingsStore::new() {
let _ = store.set_preferred_gpu(&resolved.preferred_gpu);
}
}
}
@@ -47,3 +47,60 @@ pub fn v2_apply_graphics_recommendation() -> Result<Vec<String>, Vec<String>> {
Err(errors) => Err(errors),
}
}
/// Payload for the rendering-GPU dropdown in Settings > Graphics.
/// `gpus` is the full list (physical PCI devices intersected with
/// available EGL vendor stacks, plus the Software fallback).
/// `auto_resolved_id` says which entry, if any, "Auto" currently pins
/// to on this system — frontend uses it to label the Auto option (e.g.
/// "Automatic (Intel Graphics)") so the user can see what default they
/// are getting without picking explicitly. `None` means Auto is a
/// no-op on this system (single GPU or no GPU detected).
#[derive(serde::Serialize)]
pub struct GpuList {
pub gpus: Vec<crate::graphics_detection::DetectedGpu>,
pub auto_resolved_id: Option<String>,
}
/// Enumerate detected GPUs for the rendering-GPU dropdown in
/// Settings > Graphics. Returns physical PCI devices intersected with
/// available EGL vendor stacks — entries with `is_usable = false`
/// should be hidden / disabled by the UI. Always includes a Software
/// fallback so the list is never empty.
#[tauri::command]
pub fn v2_enumerate_gpus() -> GpuList {
let gpus = crate::graphics_detection::enumerate_gpus();
let auto_resolved_id =
crate::graphics_detection::auto_resolves_to(&gpus).map(|g| g.id.clone());
GpuList {
gpus,
auto_resolved_id,
}
}
/// Called by the frontend after first paint to clear the boot
/// watchdog's pending marker. If this is never called (because WebKit
/// crashed before painting), the next launch sees the marker and
/// auto-reverts the offending risky setting.
#[tauri::command]
pub fn v2_mark_boot_succeeded() {
crate::boot_watchdog::mark_boot_succeeded();
}
/// Read the sticky crash flags so Settings > Graphics can render
/// the recovery banner. The flags say which risky setting was
/// auto-reverted in this boot, the consecutive-failure counter, and
/// whether the knob is in lockout state (2+ failures → user must
/// explicitly clear before it can be re-enabled).
#[tauri::command]
pub fn v2_get_crash_flags() -> crate::boot_watchdog::CrashFlags {
crate::boot_watchdog::get_crash_flags()
}
/// User-driven clear of a single crash recovery flag. Called when the
/// user clicks "Try again" / "Re-enable" on the banner. Valid flag
/// names: "hardware_acceleration", "force_dmabuf", "preferred_gpu".
#[tauri::command]
pub fn v2_clear_crash_flag(flag: String) {
crate::boot_watchdog::clear_crash_flag(&flag);
}
@@ -815,6 +815,21 @@ pub fn v2_set_gsk_renderer(
store.set_gsk_renderer(value)
}
#[tauri::command]
pub fn v2_set_preferred_gpu(
value: String,
state: State<'_, GraphicsSettingsState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard
.as_ref()
.ok_or("Graphics settings store not initialized")?;
store.set_preferred_gpu(&value)
}
#[tauri::command]
pub fn v2_clear_cache(state: State<'_, AppState>) -> Result<(), String> {
state.audio_cache.clear();
+51 -1
View File
@@ -62,6 +62,16 @@ pub struct GraphicsSettings {
pub gdk_dpi_scale: Option<String>,
/// GSK_RENDERER override (None = auto). Values: "gl", "ngl", "vulkan", "cairo"
pub gsk_renderer: Option<String>,
/// Rendering GPU selection. Values:
/// "auto" — let the system pick (default).
/// "integrated" — pin to the iGPU (Intel/AMD).
/// "discrete" — pin to the dGPU (NVIDIA, or AMD/Intel discrete).
/// "software" — software rendering via llvmpipe.
/// "<pci-slot>" — pin to a specific detected GPU (e.g. "0000:01:00.0").
/// main.rs applies the choice via env vars (EGL vendor pin, DRI_PRIME,
/// NV_PRIME_RENDER_OFFLOAD) before WebKit init. Requires restart to
/// take effect.
pub preferred_gpu: String,
}
impl Default for GraphicsSettings {
@@ -72,6 +82,7 @@ impl Default for GraphicsSettings {
gdk_scale: None,
gdk_dpi_scale: None,
gsk_renderer: None,
preferred_gpu: "auto".to_string(),
}
}
}
@@ -142,6 +153,13 @@ impl GraphicsSettingsStore {
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_scale TEXT;");
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_dpi_scale TEXT;");
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gsk_renderer TEXT;");
// 1.2.13: rendering GPU selection. Default 'auto' so existing
// installs keep their current behaviour after migration. Stored
// as TEXT to accept both the well-known sentinels (auto /
// integrated / discrete / software) and specific PCI slot ids.
let _ = conn.execute_batch(
"ALTER TABLE graphics_settings ADD COLUMN preferred_gpu TEXT NOT NULL DEFAULT 'auto';",
);
Ok(Self { conn })
}
@@ -149,7 +167,7 @@ impl GraphicsSettingsStore {
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
self.conn
.query_row(
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer FROM graphics_settings WHERE id = 1",
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer, preferred_gpu FROM graphics_settings WHERE id = 1",
[],
|row| {
Ok(GraphicsSettings {
@@ -158,6 +176,9 @@ impl GraphicsSettingsStore {
gdk_scale: row.get::<_, Option<String>>(2)?,
gdk_dpi_scale: row.get::<_, Option<String>>(3)?,
gsk_renderer: row.get::<_, Option<String>>(4)?,
preferred_gpu: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "auto".to_string()),
})
},
)
@@ -213,6 +234,16 @@ impl GraphicsSettingsStore {
.map_err(|e| format!("Failed to set gsk_renderer: {}", e))?;
Ok(())
}
pub fn set_preferred_gpu(&self, value: &str) -> Result<(), String> {
self.conn
.execute(
"UPDATE graphics_settings SET preferred_gpu = ?1 WHERE id = 1",
params![value],
)
.map_err(|e| format!("Failed to set preferred_gpu: {}", e))?;
Ok(())
}
}
/// Thread-safe wrapper for Tauri state management
@@ -326,6 +357,25 @@ pub fn set_gdk_dpi_scale(
store.set_gdk_dpi_scale(value)
}
#[tauri::command]
pub fn set_preferred_gpu(
state: tauri::State<'_, GraphicsSettingsState>,
value: String,
) -> Result<(), String> {
log::info!(
"[GraphicsSettings] Setting preferred_gpu to {:?} (restart required)",
value
);
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard
.as_ref()
.ok_or("Graphics settings store not initialized")?;
store.set_preferred_gpu(&value)
}
// --- Startup Status (set once at app launch, read-only thereafter) ---
/// Tracks graphics configuration status from startup
+494
View File
@@ -0,0 +1,494 @@
//! GPU enumeration for the Settings > Graphics rendering-GPU picker.
//!
//! Cross-references the PCI devices visible under `/sys/class/drm/` (real
//! physical GPUs) with the EGL vendor JSONs available under
//! `/usr/share/glvnd/egl_vendor.d/` (stacks the running process can
//! actually use). The intersection is what we expose to the user.
//!
//! Inside Flatpak/Snap the `/sys` path is still mounted (read-only) so
//! PCI detection works; the `/usr/share/glvnd/...` view reflects the
//! sandbox's own vendor JSONs which is the correct thing to read — that's
//! what the WebKit process inside the sandbox can load.
//!
//! Companion to `autoconfig_graphics` (which decides "what config should
//! we recommend") — this module answers "what hardware/stacks do we
//! actually have".
//!
//! Cross-platform contract: the public API (`enumerate_gpus`,
//! `env_vars_for_preferred_gpu`, `PreferredGpu`) compiles on every
//! platform. macOS/Windows fall through to empty enumerations and a
//! no-op env-var application — the dropdown shows only the Software
//! entry there, and the preference can still round-trip through
//! storage without breaking the build.
use serde::Serialize;
#[cfg(target_os = "linux")]
use std::fs;
#[cfg(target_os = "linux")]
use std::path::Path;
/// PCI vendor IDs we recognize.
const VENDOR_INTEL: &str = "0x8086";
const VENDOR_AMD: &str = "0x1002";
const VENDOR_NVIDIA: &str = "0x10de";
/// Coarse classification of a GPU's role on the system. Drives the
/// label the Settings dropdown shows ("Integrated" vs "Discrete") and
/// the default `preferred_gpu` selection when the user hasn't picked
/// one yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GpuKind {
Integrated,
Discrete,
/// Pure-software rendering (llvmpipe / cpu fallback). Not a real
/// device — present in the list only so the dropdown can offer
/// "Software (CPU)" as an explicit choice.
Software,
}
/// One detected GPU with everything the picker needs to render and the
/// env-var application path needs to act on the user's choice.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct DetectedGpu {
/// Stable identifier across launches: the PCI slot string for real
/// devices (e.g. "0000:01:00.0") or a sentinel for software.
pub id: String,
pub vendor: String,
/// Human-readable model where available (from drm
/// `device/product_name` or vendor heuristic fallback).
pub name: String,
/// PCI ID as `vendor:device` for diagnostics. Stripped of the `0x`
/// prefix so it reads like `8086:7d55`.
pub pci_id: Option<String>,
pub kind: GpuKind,
/// Absolute path of the EGL vendor JSON that exposes this GPU's
/// stack to the running process, if one is available. None means
/// the user's sandbox/install doesn't have the vendor library
/// reachable, in which case picking this GPU would fall back to
/// the system default.
pub egl_vendor_json: Option<String>,
/// True when the GPU is BOTH physically present AND reachable via
/// an EGL vendor JSON. The picker should grey out (or omit)
/// entries where this is false.
pub is_usable: bool,
}
/// Enumerate every GPU the user could pick. Always returns at least
/// the `Software` entry so the dropdown is never empty on systems with
/// no detectable hardware.
pub fn enumerate_gpus() -> Vec<DetectedGpu> {
let physical = detect_physical_gpus();
let egl = enumerate_egl_vendor_jsons();
let mut result: Vec<DetectedGpu> = physical
.into_iter()
.map(|p| {
let egl_path = match p.vendor.as_str() {
"NVIDIA" => egl.nvidia.clone(),
// Intel + AMD + Nouveau all ship through Mesa EGL.
_ => egl.mesa.clone(),
};
DetectedGpu {
id: p.pci_slot.clone(),
vendor: p.vendor.clone(),
name: p.name.clone(),
pci_id: Some(format!("{}:{}", p.pci_vendor_hex, p.pci_device_hex)),
kind: p.kind,
is_usable: egl_path.is_some(),
egl_vendor_json: egl_path,
}
})
.collect();
// Always expose Software as a fallback. Mesa ships llvmpipe through
// the same JSON the user can pin EGL to via LIBGL_ALWAYS_SOFTWARE=1;
// the runtime branch in main.rs handles the env wiring.
result.push(DetectedGpu {
id: "software".to_string(),
vendor: "Software".to_string(),
name: "Software (CPU, llvmpipe)".to_string(),
pci_id: None,
kind: GpuKind::Software,
egl_vendor_json: egl.mesa.clone(),
is_usable: egl.mesa.is_some(),
});
result
}
// ---- Physical PCI detection ------------------------------------------------
#[derive(Debug, Clone)]
struct PhysicalGpu {
pci_slot: String,
pci_vendor_hex: String,
pci_device_hex: String,
vendor: String,
name: String,
kind: GpuKind,
}
#[cfg(not(target_os = "linux"))]
fn detect_physical_gpus() -> Vec<PhysicalGpu> {
// macOS / Windows: no sysfs path to enumerate from. The picker will
// only show the Software fallback there, which is the right shape
// until we add platform-specific detection.
Vec::new()
}
#[cfg(not(target_os = "linux"))]
fn enumerate_egl_vendor_jsons() -> EglVendors {
EglVendors::default()
}
#[cfg(target_os = "linux")]
fn detect_physical_gpus() -> Vec<PhysicalGpu> {
let drm_root = Path::new("/sys/class/drm");
let entries = match fs::read_dir(drm_root) {
Ok(it) => it,
Err(_) => return Vec::new(),
};
let mut gpus: Vec<PhysicalGpu> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
// Match `card0`, `card1`, ... — skip the `card0-DP-1` connector
// pseudo-entries (have a dash in the name) and the renderD*
// nodes (different prefix).
if !name.starts_with("card") || name.contains('-') {
continue;
}
let device_path = entry.path().join("device");
let canonical = match fs::canonicalize(&device_path) {
Ok(p) => p,
Err(_) => continue,
};
let pci_slot = pci_slot_from_path(&canonical).unwrap_or_else(|| name.clone());
let pci_vendor_hex = read_trimmed(&device_path.join("vendor")).unwrap_or_default();
let pci_device_hex = read_trimmed(&device_path.join("device")).unwrap_or_default();
let vendor = classify_vendor(&pci_vendor_hex);
if vendor == "Unknown" {
// Not a known consumer GPU vendor — skip rather than expose
// server / virtual GPU rows the user can't really act on.
continue;
}
let product_name = read_trimmed(&device_path.join("product_name"))
.filter(|s| !s.is_empty());
let name = build_human_name(&vendor, product_name.as_deref());
gpus.push(PhysicalGpu {
pci_slot,
pci_vendor_hex: strip_hex_prefix(&pci_vendor_hex),
pci_device_hex: strip_hex_prefix(&pci_device_hex),
vendor,
name,
// Kind needs the full list to compare slots → fill in below.
kind: GpuKind::Discrete,
});
}
classify_kinds(&mut gpus);
gpus
}
#[cfg(target_os = "linux")]
fn pci_slot_from_path(path: &Path) -> Option<String> {
// /sys/devices/pci0000:00/0000:00:01.0/0000:01:00.0 → "0000:01:00.0"
path.file_name().map(|s| s.to_string_lossy().to_string())
}
#[cfg(target_os = "linux")]
fn read_trimmed(p: &Path) -> Option<String> {
fs::read_to_string(p).ok().map(|s| s.trim().to_string())
}
fn strip_hex_prefix(s: &str) -> String {
s.strip_prefix("0x").unwrap_or(s).to_string()
}
fn classify_vendor(pci_vendor: &str) -> String {
match pci_vendor {
VENDOR_INTEL => "Intel".to_string(),
VENDOR_AMD => "AMD".to_string(),
VENDOR_NVIDIA => "NVIDIA".to_string(),
_ => "Unknown".to_string(),
}
}
fn build_human_name(vendor: &str, product: Option<&str>) -> String {
match (vendor, product) {
(_, Some(p)) if !p.is_empty() => p.to_string(),
("Intel", None) => "Intel Graphics".to_string(),
("AMD", None) => "AMD Graphics".to_string(),
("NVIDIA", None) => "NVIDIA Graphics".to_string(),
_ => "GPU".to_string(),
}
}
/// Classify each GPU as Integrated vs Discrete using a small heuristic
/// over the full list:
/// - NVIDIA is always Discrete (consumer NVIDIA iGPUs don't exist).
/// - Intel: if there is also a NVIDIA/AMD discrete present, Intel is
/// Integrated. If Intel is alone, infer from PCI slot — slot
/// `0000:00:...` (root bus) is integrated, anything beyond is
/// treated as discrete (covers Intel Arc desktop cards).
/// - AMD: tricky. If there is also a NVIDIA, AMD is the iGPU (APU).
/// If AMD is alone, infer from slot like Intel.
fn classify_kinds(gpus: &mut [PhysicalGpu]) {
let has_nvidia = gpus.iter().any(|g| g.vendor == "NVIDIA");
let has_amd = gpus.iter().any(|g| g.vendor == "AMD");
for g in gpus.iter_mut() {
g.kind = match g.vendor.as_str() {
"NVIDIA" => GpuKind::Discrete,
"Intel" => {
if has_nvidia || has_amd {
GpuKind::Integrated
} else if slot_is_root_bus(&g.pci_slot) {
GpuKind::Integrated
} else {
GpuKind::Discrete
}
}
"AMD" => {
if has_nvidia {
GpuKind::Integrated
} else if slot_is_root_bus(&g.pci_slot) {
GpuKind::Integrated
} else {
GpuKind::Discrete
}
}
_ => GpuKind::Discrete,
};
}
}
fn slot_is_root_bus(slot: &str) -> bool {
// PCI slot format: "DDDD:BB:DD.F" where BB is the bus. Root bus is 00.
slot.split(':').nth(1).map(|bb| bb == "00").unwrap_or(false)
}
// ---- EGL vendor JSON enumeration ------------------------------------------
#[derive(Debug, Clone, Default)]
struct EglVendors {
mesa: Option<String>,
nvidia: Option<String>,
}
#[cfg(target_os = "linux")]
fn enumerate_egl_vendor_jsons() -> EglVendors {
let candidates: [&str; 2] = [
"/usr/share/glvnd/egl_vendor.d",
"/etc/glvnd/egl_vendor.d",
];
let mut out = EglVendors::default();
for root in candidates {
let Ok(entries) = fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !file_name.ends_with(".json") {
continue;
}
let lower = file_name.to_lowercase();
let abs = path.to_string_lossy().to_string();
if lower.contains("nvidia") {
if out.nvidia.is_none() {
out.nvidia = Some(abs);
}
} else if lower.contains("mesa") {
if out.mesa.is_none() {
out.mesa = Some(abs);
}
}
}
}
out
}
/// An action to apply to the process environment. We need `Unset` (not
/// just `Set`) because the parent shell/session may have already set
/// vars that pull rendering back to the wrong GPU — most importantly
/// `__NV_PRIME_RENDER_OFFLOAD=1` on Optimus laptops, which makes NVIDIA
/// hijack the render even after we pin EGL to Mesa.
#[derive(Debug, Clone)]
pub enum EnvAction {
Set(String, String),
Unset(String),
}
/// Decide what `PreferredGpu::Auto` should pin on this system. The
/// rule: if we detect both an iGPU and a dGPU that are usable, pin to
/// the iGPU — that's the sweet-spot config for hybrid laptops (Optimus,
/// Mux-less Ryzen, etc.) where the dGPU otherwise hijacks WebKit
/// rendering and wastes battery. Single-GPU or no-GPU systems return
/// None → caller leaves env alone and the system default wins (correct
/// behavior when there's nothing to choose between).
pub fn auto_resolves_to<'a>(detected: &'a [DetectedGpu]) -> Option<&'a DetectedGpu> {
let integrated = detected
.iter()
.find(|g| g.kind == GpuKind::Integrated && g.is_usable);
let has_discrete = detected
.iter()
.any(|g| g.kind == GpuKind::Discrete && g.is_usable);
if integrated.is_some() && has_discrete {
integrated
} else {
None
}
}
/// Build the env actions that pin the running process to a specific GPU
/// stack. The caller (main.rs at startup) applies them via
/// `std::env::set_var` / `remove_var` before WebKit initializes.
/// Returning an empty vec means "auto with no resolution" — don't touch
/// the environment.
pub fn env_vars_for_preferred_gpu(
preferred: &PreferredGpu,
detected: &[DetectedGpu],
) -> Vec<EnvAction> {
match preferred {
PreferredGpu::Auto => match auto_resolves_to(detected) {
Some(g) => env_vars_for_gpu(g),
None => Vec::new(),
},
PreferredGpu::Software => {
// Drop to software rendering across every layer that can
// sneak the GPU back in. Same set as scripts/dev-cpu-mode.sh.
vec![
EnvAction::Set("LIBGL_ALWAYS_SOFTWARE".to_string(), "1".to_string()),
EnvAction::Set("WEBKIT_DISABLE_DMABUF_RENDERER".to_string(), "1".to_string()),
EnvAction::Set("GSK_RENDERER".to_string(), "cairo".to_string()),
EnvAction::Unset("__NV_PRIME_RENDER_OFFLOAD".to_string()),
]
}
PreferredGpu::Kind(kind) => {
let target = detected
.iter()
.find(|g| g.kind == *kind && g.is_usable);
let Some(g) = target else {
return Vec::new();
};
env_vars_for_gpu(g)
}
PreferredGpu::SpecificId(id) => {
let target = detected.iter().find(|g| &g.id == id);
let Some(g) = target else {
return Vec::new();
};
env_vars_for_gpu(g)
}
}
}
fn env_vars_for_gpu(g: &DetectedGpu) -> Vec<EnvAction> {
let mut env: Vec<EnvAction> = Vec::new();
if let Some(path) = &g.egl_vendor_json {
env.push(EnvAction::Set(
"__EGL_VENDOR_LIBRARY_FILENAMES".to_string(),
path.clone(),
));
}
match g.vendor.as_str() {
"Intel" | "AMD" => {
env.push(EnvAction::Set(
"__GLX_VENDOR_LIBRARY_NAME".to_string(),
"mesa".to_string(),
));
env.push(EnvAction::Set("DRI_PRIME".to_string(), "0".to_string()));
// Kill any NVIDIA PRIME offload the session might have set.
// Without this, on Optimus laptops the dGPU still wins even
// with EGL/GLX pinned to Mesa — `__NV_PRIME_RENDER_OFFLOAD=1`
// takes priority and routes WebKit composition back to
// NVIDIA. Mirrors `unset __NV_PRIME_RENDER_OFFLOAD` in
// scripts/dev-igpu-mode.sh.
env.push(EnvAction::Unset(
"__NV_PRIME_RENDER_OFFLOAD".to_string(),
));
}
"NVIDIA" => {
env.push(EnvAction::Set(
"__NV_PRIME_RENDER_OFFLOAD".to_string(),
"1".to_string(),
));
env.push(EnvAction::Set(
"__GLX_VENDOR_LIBRARY_NAME".to_string(),
"nvidia".to_string(),
));
}
_ => {}
}
env
}
/// User-facing preference. Mirrors the column in graphics_settings.db
/// (free-form text with a small known set of values).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreferredGpu {
Auto,
Software,
Kind(GpuKind),
SpecificId(String),
}
impl PreferredGpu {
pub fn parse(raw: &str) -> Self {
match raw.trim().to_lowercase().as_str() {
"" | "auto" => Self::Auto,
"software" | "cpu" => Self::Software,
"integrated" | "igpu" => Self::Kind(GpuKind::Integrated),
"discrete" | "dgpu" => Self::Kind(GpuKind::Discrete),
other => Self::SpecificId(other.to_string()),
}
}
pub fn as_storage_string(&self) -> String {
match self {
Self::Auto => "auto".to_string(),
Self::Software => "software".to_string(),
Self::Kind(GpuKind::Integrated) => "integrated".to_string(),
Self::Kind(GpuKind::Discrete) => "discrete".to_string(),
Self::Kind(GpuKind::Software) => "software".to_string(),
Self::SpecificId(id) => id.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferred_gpu_round_trip() {
for raw in ["auto", "software", "integrated", "discrete", "0000:01:00.0"] {
let parsed = PreferredGpu::parse(raw);
assert_eq!(parsed.as_storage_string(), raw);
}
}
#[test]
fn slot_root_bus_classification() {
assert!(slot_is_root_bus("0000:00:02.0"));
assert!(slot_is_root_bus("0000:00:01.0"));
assert!(!slot_is_root_bus("0000:01:00.0"));
assert!(!slot_is_root_bus("0000:02:00.0"));
}
#[test]
fn vendor_classification() {
assert_eq!(classify_vendor(VENDOR_INTEL), "Intel");
assert_eq!(classify_vendor(VENDOR_AMD), "AMD");
assert_eq!(classify_vendor(VENDOR_NVIDIA), "NVIDIA");
assert_eq!(classify_vendor("0xdeadbeef"), "Unknown");
}
}
+7
View File
@@ -25,6 +25,8 @@ pub fn main_window_built_transparent() -> bool {
}
#[cfg(target_os = "linux")]
pub mod autoconfig_graphics;
pub mod boot_watchdog;
pub mod graphics_detection;
pub mod api;
pub mod api_cache;
@@ -1656,8 +1658,13 @@ pub fn run(qconnect_cli_override: Option<bool>) {
commands_v2::v2_set_gdk_scale,
commands_v2::v2_set_gdk_dpi_scale,
commands_v2::v2_set_gsk_renderer,
commands_v2::v2_set_preferred_gpu,
commands_v2::v2_get_graphics_recommendation,
commands_v2::v2_apply_graphics_recommendation,
commands_v2::v2_enumerate_gpus,
commands_v2::v2_mark_boot_succeeded,
commands_v2::v2_get_crash_flags,
commands_v2::v2_clear_crash_flag,
commands_v2::v2_clear_cache,
commands_v2::v2_clear_artist_cache,
commands_v2::v2_get_vector_store_stats,
+78 -4
View File
@@ -288,8 +288,14 @@ fn main() {
}
};
// Developer settings: force_dmabuf override (from Settings > Developer Mode)
let dev_force_dmabuf =
// ---- Read remaining risky settings (no env vars set yet) ----
let preferred_gpu_raw_intended = graphics_db
.as_ref()
.map(|s| s.preferred_gpu.clone())
.unwrap_or_else(|| "auto".to_string());
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
let dev_force_dmabuf_intended =
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
Ok(store) => match store.get_settings() {
Ok(settings) => settings.force_dmabuf,
@@ -309,6 +315,74 @@ fn main() {
false
}
};
// ---- Boot watchdog: revert risky settings if previous boot crashed ----
// If a prior launch wrote a pending marker that never got cleared,
// those settings crashed WebKit before first paint. The watchdog
// returns the resolved values to actually use this boot (usually
// the intended ones, with revert flags set for any knob that
// matched the prior crash). It also writes a fresh pending marker
// for THIS boot; the frontend's first-paint hook clears it.
let watchdog_resolution = qbz_nix_lib::boot_watchdog::before_webkit_init(
qbz_nix_lib::boot_watchdog::BootAttempt {
hardware_acceleration: hardware_accel,
force_dmabuf: dev_force_dmabuf_intended,
preferred_gpu: preferred_gpu_raw_intended.clone(),
force_x11: force_x11_db,
},
);
let hardware_accel = watchdog_resolution.hardware_acceleration;
let dev_force_dmabuf = watchdog_resolution.force_dmabuf;
let preferred_gpu_raw = watchdog_resolution.preferred_gpu.clone();
for msg in &watchdog_resolution.recovery_messages {
eprintln!("[QBZ] {}", msg);
qbz_nix_lib::logging::log_startup(&format!("[QBZ] {}", msg));
}
// ---- Apply preferred_gpu env vars (using watchdog-resolved value) ----
let preferred_gpu_choice =
qbz_nix_lib::graphics_detection::PreferredGpu::parse(&preferred_gpu_raw);
if !matches!(
preferred_gpu_choice,
qbz_nix_lib::graphics_detection::PreferredGpu::Auto
) {
let detected = qbz_nix_lib::graphics_detection::enumerate_gpus();
let env_pairs = qbz_nix_lib::graphics_detection::env_vars_for_preferred_gpu(
&preferred_gpu_choice,
&detected,
);
if env_pairs.is_empty() {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] preferred_gpu={:?} could not be resolved (no matching detected GPU); falling back to system default",
preferred_gpu_raw
));
} else {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] preferred_gpu={:?}: applying GPU pin env vars",
preferred_gpu_raw
));
for action in &env_pairs {
match action {
qbz_nix_lib::graphics_detection::EnvAction::Set(name, value) => {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] set {}={}",
name, value
));
std::env::set_var(name, value);
}
qbz_nix_lib::graphics_detection::EnvAction::Unset(name) => {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] unset {}",
name
));
std::env::remove_var(name);
}
}
}
}
}
// ---- Apply dev_force_dmabuf env var (using watchdog-resolved value) ----
if dev_force_dmabuf {
std::env::set_var("QBZ_FORCE_DMABUF", "1");
qbz_nix_lib::logging::log_startup(
@@ -321,8 +395,8 @@ fn main() {
let force_dmabuf = std::env::var("QBZ_FORCE_DMABUF").as_deref() == Ok("1");
let disable_dmabuf = std::env::var("QBZ_DISABLE_DMABUF").as_deref() == Ok("1");
// Force X11: persistent setting from DB, env var overrides (crash recovery)
let force_x11_db = graphics_db.as_ref().map(|s| s.force_x11).unwrap_or(false);
// Force X11: persistent setting from DB (read above for watchdog),
// env var overrides (crash recovery).
let force_x11 = match std::env::var("QBZ_FORCE_X11").as_deref() {
Ok("1") => {
qbz_nix_lib::logging::log_startup("[QBZ] Env override: QBZ_FORCE_X11=1");
@@ -389,6 +389,49 @@
let gdkScale = $state('');
let gdkDpiScale = $state('');
let gskRenderer = $state('');
let preferredGpu = $state('auto');
// Rendering-GPU detection for the Settings > Graphics dropdown. The
// backend cross-references PCI devices visible under /sys/class/drm/
// with EGL vendor JSONs available to the running process; the result
// lists every GPU the user can practically pick (auto + each usable
// discrete/integrated + software fallback). Loaded on mount.
type DetectedGpu = {
id: string;
vendor: string;
name: string;
pci_id: string | null;
kind: 'integrated' | 'discrete' | 'software';
egl_vendor_json: string | null;
is_usable: boolean;
};
let detectedGpus = $state<DetectedGpu[]>([]);
// Which GPU the backend currently resolves "Auto" to on this system.
// Non-null only on hybrid setups (iGPU + dGPU) where Auto pins to the
// iGPU; single-GPU and no-GPU systems get null and the dropdown shows
// a plain "Automatic" label without a sub-name.
let autoResolvedGpuId = $state<string | null>(null);
type GpuList = {
gpus: DetectedGpu[];
auto_resolved_id: string | null;
};
// Sticky crash recovery flags from the boot watchdog. If a previous
// launch crashed during graphics init, the offending knob was
// auto-reverted and one of these flags is true. Settings > Graphics
// renders a banner so the user can retry or keep the safe setting.
type CrashFlags = {
hardware_acceleration_disabled: boolean;
hardware_acceleration_consecutive_failures: number;
force_dmabuf_disabled: boolean;
force_dmabuf_consecutive_failures: number;
preferred_gpu_disabled: boolean;
preferred_gpu_consecutive_failures: number;
hardware_acceleration_locked: boolean;
force_dmabuf_locked: boolean;
preferred_gpu_locked: boolean;
};
let crashFlags = $state<CrashFlags | null>(null);
// Graphics recommendation surfaced in the Graphics tab. Populated on
// mount via v2_get_graphics_recommendation (matrix lives in
@@ -1836,6 +1879,7 @@
gdkDpiScale = settings.gdk_dpi_scale || '';
gskRenderer = settings.gsk_renderer || '';
hardwareAcceleration = settings.hardware_acceleration;
preferredGpu = settings.preferred_gpu || 'auto';
}).catch(() => {});
// Load graphics startup status (for fallback warning)
@@ -1856,6 +1900,21 @@
.catch((err) => {
console.warn('[Settings] graphics recommendation unavailable:', err);
});
invoke<GpuList>('v2_enumerate_gpus')
.then((payload) => {
detectedGpus = payload.gpus;
autoResolvedGpuId = payload.auto_resolved_id;
})
.catch((err) => {
console.warn('[Settings] gpu enumeration unavailable:', err);
});
invoke<CrashFlags>('v2_get_crash_flags')
.then((flags) => {
crashFlags = flags;
})
.catch((err) => {
console.warn('[Settings] crash flags unavailable:', err);
});
}
// Subscribe to offline state changes
@@ -4113,6 +4172,67 @@
}
}
async function handlePreferredGpuChange(value: string) {
try {
await invoke('v2_set_preferred_gpu', { value });
preferredGpu = value;
showToast($t('settings.graphics.restartRequired'), 'info');
} catch (err) {
console.error('Failed to set preferred_gpu:', err);
showToast(String(err), 'error');
}
}
async function handleClearCrashFlag(flag: 'hardware_acceleration' | 'force_dmabuf' | 'preferred_gpu') {
try {
await invoke('v2_clear_crash_flag', { flag });
const flags = await invoke<CrashFlags>('v2_get_crash_flags');
crashFlags = flags;
// If the user just cleared the lockout, also re-apply the
// setting they probably wanted. Hardware acceleration: the user
// explicitly wants it back ON; same for force_dmabuf and a
// non-auto preferred_gpu. We don't auto-re-enable here because
// the watchdog already wrote the safer value to DB; the user
// needs to re-toggle to express intent. Toast hints what to do.
showToast($t('settings.graphics.recovery.cleared'), 'success');
} catch (err) {
console.error('Failed to clear crash flag:', err);
showToast(String(err), 'error');
}
}
const preferredGpuOptions = $derived.by(() => {
const autoTarget = autoResolvedGpuId
? detectedGpus.find((g) => g.id === autoResolvedGpuId)
: null;
const autoLabel = autoTarget
? $t('settings.graphics.gpu.autoWithTarget', { values: { name: autoTarget.name } })
: $t('settings.graphics.gpu.auto');
const opts: { value: string; label: string }[] = [
{ value: 'auto', label: autoLabel },
];
let hasIntegrated = false;
let hasDiscrete = false;
for (const g of detectedGpus) {
if (!g.is_usable) continue;
if (g.kind === 'integrated' && !hasIntegrated) {
opts.push({ value: 'integrated', label: $t('settings.graphics.gpu.integrated', { values: { name: g.name } }) });
hasIntegrated = true;
}
if (g.kind === 'discrete' && !hasDiscrete) {
opts.push({ value: 'discrete', label: $t('settings.graphics.gpu.discrete', { values: { name: g.name } }) });
hasDiscrete = true;
}
}
opts.push({ value: 'software', label: $t('settings.graphics.gpu.software') });
return opts;
});
function getPreferredGpuDisplayValue(): string {
const match = preferredGpuOptions.find((o) => o.value === preferredGpu);
return match ? match.label : preferredGpu;
}
// Whether the auto-detected recommendation diverges from any of the
// four persisted graphics settings. The DMA-BUF axis is inverted:
// recommendation `disable_dmabuf=true` maps to `force_dmabuf=false`
@@ -5391,6 +5511,47 @@
</div>
{/if}
{#if crashFlags && (crashFlags.hardware_acceleration_disabled || crashFlags.force_dmabuf_disabled || crashFlags.preferred_gpu_disabled)}
<div class="crash-recovery-banner" class:locked={crashFlags.hardware_acceleration_locked || crashFlags.force_dmabuf_locked || crashFlags.preferred_gpu_locked}>
<TriangleAlert size={16} />
<div class="crash-recovery-body">
<span class="crash-recovery-title">
{#if crashFlags.hardware_acceleration_locked || crashFlags.force_dmabuf_locked || crashFlags.preferred_gpu_locked}
{$t('settings.graphics.recovery.titleLocked')}
{:else}
{$t('settings.graphics.recovery.title')}
{/if}
</span>
<ul class="crash-recovery-list">
{#if crashFlags.hardware_acceleration_disabled}
<li>
<span>{$t('settings.graphics.recovery.hwAccelDisabled')}</span>
<button class="link-btn" onclick={() => handleClearCrashFlag('hardware_acceleration')}>
{$t('settings.graphics.recovery.tryAgain')}
</button>
</li>
{/if}
{#if crashFlags.force_dmabuf_disabled}
<li>
<span>{$t('settings.graphics.recovery.dmabufDisabled')}</span>
<button class="link-btn" onclick={() => handleClearCrashFlag('force_dmabuf')}>
{$t('settings.graphics.recovery.tryAgain')}
</button>
</li>
{/if}
{#if crashFlags.preferred_gpu_disabled}
<li>
<span>{$t('settings.graphics.recovery.gpuPinDisabled')}</span>
<button class="link-btn" onclick={() => handleClearCrashFlag('preferred_gpu')}>
{$t('settings.graphics.recovery.tryAgain')}
</button>
</li>
{/if}
</ul>
</div>
</div>
{/if}
{#if graphicsUsingFallback}
<div class="composition-warning fallback-warning">
<TriangleAlert size={14} />
@@ -5436,6 +5597,23 @@
<Toggle enabled={hardwareAcceleration} onchange={(v) => handleHardwareAccelerationChange(v)} />
</div>
{#if preferredGpuOptions.length > 1}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.graphics.gpu.label')}</span>
<span class="setting-desc">{$t('settings.graphics.gpu.desc')}</span>
</div>
<Dropdown
value={getPreferredGpuDisplayValue()}
options={preferredGpuOptions.map((o) => o.label)}
onchange={(displayLabel) => {
const match = preferredGpuOptions.find((o) => o.label === displayLabel);
if (match) void handlePreferredGpuChange(match.value);
}}
/>
</div>
{/if}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.graphics.forceDmabuf')}</span>
@@ -7246,6 +7424,83 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
gap: 8px;
}
/* Crash-recovery banner. Yellow when a single setting was reverted
(recoverable), red when one or more knobs hit lockout state after
2+ consecutive crashes — the user has to explicitly clear the flag
to re-enable. */
.crash-recovery-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 8px;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--text-secondary);
}
.crash-recovery-banner.locked {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.35);
}
.crash-recovery-banner :global(svg) {
flex-shrink: 0;
color: #f59e0b;
margin-top: 2px;
}
.crash-recovery-banner.locked :global(svg) {
color: #ef4444;
}
.crash-recovery-body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.crash-recovery-title {
font-weight: 600;
color: var(--text-primary);
font-size: 13px;
}
.crash-recovery-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.crash-recovery-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
}
.link-btn {
background: transparent;
color: var(--accent-primary, #7c3aed);
border: none;
padding: 0;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.link-btn:hover {
filter: brightness(1.15);
}
.primary-btn {
background: var(--accent-primary, #7c3aed);
color: var(--btn-primary-text, #fff);
+19
View File
@@ -2830,6 +2830,25 @@
"dismiss": "Verwerfen",
"redetect": "Empfohlene Einstellungen erkennen",
"noChanges": "Die Einstellungen entsprechen bereits der Empfehlung für dieses System."
},
"restartRequired": "QBZ neu starten, damit die Änderung wirksam wird.",
"gpu": {
"label": "Rendering-GPU",
"desc": "WebKit an eine bestimmte GPU binden. Neustart erforderlich. Auto überlässt die Wahl dem System.",
"auto": "Automatisch (System-Standard)",
"autoWithTarget": "Automatisch ({name})",
"integrated": "Integriert — {name}",
"discrete": "Dediziert — {name}",
"software": "Software (CPU, llvmpipe)"
},
"recovery": {
"title": "Letzter Start während Grafik-Init abgestürzt",
"titleLocked": "Wegen wiederholter Grafikabstürze deaktiviert",
"hwAccelDisabled": "Hardware-Beschleunigung wurde zurückgesetzt.",
"dmabufDisabled": "DMA-BUF-Renderer wurde zurückgesetzt.",
"gpuPinDisabled": "GPU-Auswahl wurde auf Automatisch zurückgesetzt.",
"tryAgain": "Erneut versuchen",
"cleared": "Absturzmarkierung gelöscht. Aktiviere die Einstellung erneut, wenn du es nochmal versuchen möchtest."
}
}
},
+19
View File
@@ -2836,6 +2836,25 @@
"dismiss": "Dismiss",
"redetect": "Detect recommended settings",
"noChanges": "Settings already match the recommendation for this system."
},
"restartRequired": "Restart QBZ for the change to take effect.",
"gpu": {
"label": "Rendering GPU",
"desc": "Pin WebKit to a specific GPU stack. Restart required. Auto lets the system pick.",
"auto": "Auto (system default)",
"autoWithTarget": "Auto ({name})",
"integrated": "Integrated — {name}",
"discrete": "Discrete — {name}",
"software": "Software (CPU, llvmpipe)"
},
"recovery": {
"title": "Last launch crashed during graphics init",
"titleLocked": "Disabled after repeated graphics crashes",
"hwAccelDisabled": "Hardware acceleration was reverted.",
"dmabufDisabled": "DMA-BUF renderer was reverted.",
"gpuPinDisabled": "Rendering GPU preference was reset to Auto.",
"tryAgain": "Try again",
"cleared": "Crash flag cleared. Re-enable the setting in Settings if you want to retry."
}
}
},
+19
View File
@@ -2836,6 +2836,25 @@
"dismiss": "Descartar",
"redetect": "Detectar configuracion recomendada",
"noChanges": "La configuracion actual ya coincide con la recomendacion para este sistema."
},
"restartRequired": "Reinicia QBZ para que el cambio surta efecto.",
"gpu": {
"label": "GPU de renderizado",
"desc": "Fija WebKit a una stack GPU especifica. Reinicio requerido. Auto deja que el sistema decida.",
"auto": "Auto (predeterminado del sistema)",
"autoWithTarget": "Auto ({name})",
"integrated": "Integrada — {name}",
"discrete": "Dedicada — {name}",
"software": "Software (CPU, llvmpipe)"
},
"recovery": {
"title": "El arranque anterior fallo durante el init de graficos",
"titleLocked": "Deshabilitado tras varios fallos de graficos",
"hwAccelDisabled": "Se revertio la aceleracion por hardware.",
"dmabufDisabled": "Se revertio el renderizador DMA-BUF.",
"gpuPinDisabled": "La preferencia de GPU se reseteo a Auto.",
"tryAgain": "Reintentar",
"cleared": "Marca de fallo limpiada. Reactiva el setting en Settings si quieres reintentar."
}
}
},
+19
View File
@@ -2830,6 +2830,25 @@
"dismiss": "Ignorer",
"redetect": "Détecter les paramètres recommandés",
"noChanges": "Les paramètres correspondent déjà à la recommandation pour ce système."
},
"restartRequired": "Redémarrez QBZ pour que le changement prenne effet.",
"gpu": {
"label": "GPU de rendu",
"desc": "Forcer WebKit sur une pile GPU spécifique. Redémarrage requis. Auto laisse le système décider.",
"auto": "Auto (par défaut)",
"autoWithTarget": "Auto ({name})",
"integrated": "Intégré — {name}",
"discrete": "Dédié — {name}",
"software": "Logiciel (CPU, llvmpipe)"
},
"recovery": {
"title": "Le démarrage précédent a planté pendant l'init graphique",
"titleLocked": "Désactivé après plusieurs plantages graphiques",
"hwAccelDisabled": "L'accélération matérielle a été désactivée.",
"dmabufDisabled": "Le moteur DMA-BUF a été désactivé.",
"gpuPinDisabled": "La préférence GPU a été réinitialisée sur Auto.",
"tryAgain": "Réessayer",
"cleared": "Indicateur de plantage effacé. Réactivez le paramètre dans Settings pour réessayer."
}
}
},
+19
View File
@@ -2830,6 +2830,25 @@
"dismiss": "Descartar",
"redetect": "Detectar configuracoes recomendadas",
"noChanges": "As configuracoes ja correspondem a recomendacao para este sistema."
},
"restartRequired": "Reinicie o QBZ para que a mudanca tenha efeito.",
"gpu": {
"label": "GPU de renderizacao",
"desc": "Fixar o WebKit em uma stack GPU especifica. Necessario reiniciar. Auto deixa o sistema decidir.",
"auto": "Auto (padrao do sistema)",
"autoWithTarget": "Auto ({name})",
"integrated": "Integrada — {name}",
"discrete": "Dedicada — {name}",
"software": "Software (CPU, llvmpipe)"
},
"recovery": {
"title": "O ultimo arranque travou durante o init grafico",
"titleLocked": "Desativado apos travamentos graficos repetidos",
"hwAccelDisabled": "A aceleracao de hardware foi revertida.",
"dmabufDisabled": "O renderer DMA-BUF foi revertido.",
"gpuPinDisabled": "A preferencia de GPU foi resetada para Auto.",
"tryAgain": "Tentar novamente",
"cleared": "Sinalizador de travamento limpo. Reative a configuracao em Settings se quiser tentar novamente."
}
}
},
+10
View File
@@ -4734,6 +4734,16 @@
// Bootstrap app (theme, mouse nav, Last.fm restore)
const { cleanup: cleanupBootstrap } = bootstrapApp();
// Tell the boot watchdog that the UI mounted successfully. This
// clears the pending-graphics-init marker the backend wrote at
// startup; if we never reach this line (WebKit crashed during
// graphics init) the next launch sees the stale marker and
// auto-reverts the offending risky setting. Idempotent and safe to
// call on every mount — backend just removes the file if present.
void invoke('v2_mark_boot_succeeded').catch((err) => {
console.warn('[BootWatchdog] mark_boot_succeeded failed:', err);
});
void initDiscordRpc();
// Window-title preference: load from localStorage and subscribe so that