From 4ab26737a9edb6f1e0128ec37b9260fb563a33ae Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 23 Jul 2026 14:01:18 +0900 Subject: [PATCH 1/2] fix(win): strengthen GPU software fallback for virtual displays The existing crash-burst fallback only applied disableHardwareAcceleration + --disable-gpu. On headless virtual adapters (e.g. Sunshine Zako) Chromium still forks a GPU child that STATUS_BREAKPOINT-crashes under those flags, so the relaunch never reaches ready (#10093). Apply the proven software combo (--in-process-gpu --disable-gpu --use-angle=swiftshader) when the sticky marker engages, and allow ORCA_SOFTWARE_GPU=1 for first-launch opt-in without waiting for a crash burst. --- src/main/index.ts | 30 ++++++++-- src/main/startup/windows-software-gpu.test.ts | 55 +++++++++++++++++++ src/main/startup/windows-software-gpu.ts | 42 ++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 src/main/startup/windows-software-gpu.test.ts create mode 100644 src/main/startup/windows-software-gpu.ts diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94b..6eac2775a7ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,6 +135,10 @@ import { type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' +import { + applyWindowsSoftwareGpuFallback, + isSoftwareGpuEnvRequested +} from './startup/windows-software-gpu' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -1570,22 +1574,36 @@ function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | nul return { ...environment, platform: 'win32' } } -// Why: read the GPU-fallback marker before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. +// Why: software GPU flags must be set before app.whenReady(); marker is sticky for this build, ORCA_SOFTWARE_GPU opts in without a crash burst. function maybeApplyGpuFallbackForThisLaunch(): void { if (isServeMode || process.platform !== 'win32') { return } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) - if (!marker) { + const envRequested = isSoftwareGpuEnvRequested() + const marker = envRequested + ? null + : readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) + if (!envRequested && !marker) { return } - app.disableHardwareAcceleration() - const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + let appliedSwitches: readonly string[] = [] + if (envRequested) { + // Why: #10093 — operator first-launch opt-in for virtual displays (ORCA_SOFTWARE_GPU). + // Post-crash marker path keeps main's non-SwiftShader switches (security: in-process + no untrusted WebGL parse). + applyWindowsSoftwareGpuFallback(app) + appliedSwitches = WINDOWS_SOFTWARE_GPU_SWITCHES.map((flag) => + 'value' in flag && flag.value !== undefined ? `${flag.name}=${flag.value}` : flag.name + ) + } else { + app.disableHardwareAcceleration() + appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + } gpuFallbackActiveThisLaunch = true // Why: with no GPU child left, child-process-gone can't report a GPU fault, so // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow, + crashesInWindow: marker?.crashesInWindow ?? 0, + source: envRequested ? 'env' : 'marker', switches: appliedSwitches.join(',') }) } diff --git a/src/main/startup/windows-software-gpu.test.ts b/src/main/startup/windows-software-gpu.test.ts new file mode 100644 index 000000000000..c10f3f4808a2 --- /dev/null +++ b/src/main/startup/windows-software-gpu.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { + WINDOWS_SOFTWARE_GPU_SWITCHES, + applyWindowsSoftwareGpuFallback, + isSoftwareGpuEnvRequested +} from './windows-software-gpu' + +function createAppMock() { + return { + disableHardwareAcceleration: vi.fn(), + commandLine: { + appendSwitch: vi.fn() + } + } +} + +describe('isSoftwareGpuEnvRequested', () => { + it('accepts common truthy ORCA_SOFTWARE_GPU values', () => { + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: '1' })).toBe(true) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: 'true' })).toBe(true) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: 'YES' })).toBe(true) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: ' 1 ' })).toBe(true) + }) + + it('rejects missing or non-truthy values', () => { + expect(isSoftwareGpuEnvRequested({})).toBe(false) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: '0' })).toBe(false) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: 'false' })).toBe(false) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: '' })).toBe(false) + }) +}) + +describe('applyWindowsSoftwareGpuFallback', () => { + it('disables hardware acceleration and applies the #10093 software combo', () => { + const app = createAppMock() + applyWindowsSoftwareGpuFallback(app) + + expect(app.disableHardwareAcceleration).toHaveBeenCalledTimes(1) + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('in-process-gpu') + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('use-angle', 'swiftshader') + }) + + it('covers every documented software switch exactly once', () => { + const app = createAppMock() + applyWindowsSoftwareGpuFallback(app) + + expect(WINDOWS_SOFTWARE_GPU_SWITCHES.map((s) => s.name)).toEqual([ + 'disable-gpu', + 'in-process-gpu', + 'use-angle' + ]) + expect(app.commandLine.appendSwitch).toHaveBeenCalledTimes(WINDOWS_SOFTWARE_GPU_SWITCHES.length) + }) +}) diff --git a/src/main/startup/windows-software-gpu.ts b/src/main/startup/windows-software-gpu.ts new file mode 100644 index 000000000000..0066d8940978 --- /dev/null +++ b/src/main/startup/windows-software-gpu.ts @@ -0,0 +1,42 @@ +/** + * Windows software-GPU command-line path used when hardware acceleration is + * unusable (broken drivers, headless virtual displays / Sunshine Zako, etc.). + * + * Why more than disable-gpu: on some virtual adapters Chromium still forks a + * GPU child that STATUS_BREAKPOINT-crashes even under --disable-gpu. The only + * combo known to reach ready is in-process GPU + ANGLE SwiftShader (#10093). + */ + +export type SoftwareGpuCommandLineApp = { + disableHardwareAcceleration: () => void + commandLine: { + appendSwitch: (switchName: string, value?: string) => void + } +} + +/** Chromium switches that form the proven software-render recovery path. */ +export const WINDOWS_SOFTWARE_GPU_SWITCHES = [ + { name: 'disable-gpu' }, + { name: 'in-process-gpu' }, + { name: 'use-angle', value: 'swiftshader' } +] as const + +/** + * True when the operator forces software GPU before any crash burst + * (headless / virtual-display hosts that never survive the first launch). + */ +export function isSoftwareGpuEnvRequested(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = (env.ORCA_SOFTWARE_GPU ?? '').trim().toLowerCase() + return raw === '1' || raw === 'true' || raw === 'yes' +} + +export function applyWindowsSoftwareGpuFallback(app: SoftwareGpuCommandLineApp): void { + app.disableHardwareAcceleration() + for (const flag of WINDOWS_SOFTWARE_GPU_SWITCHES) { + if ('value' in flag && flag.value !== undefined) { + app.commandLine.appendSwitch(flag.name, flag.value) + } else { + app.commandLine.appendSwitch(flag.name) + } + } +} From 90ffcece20adf47a5962f778660d99a0214cd178 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sun, 26 Jul 2026 12:09:23 +0900 Subject: [PATCH 2/2] fix(win): notify when software GPU fallback is active Surface a one-shot info dialog when Windows software GPU / SwiftShader fallback engages, so users know rendering is in a slower stability mode. Marker path notifies on first activation only; env opt-in is once per session. --- src/main/index.ts | 84 ++++++++++++++++++- src/main/startup/gpu-fallback-marker.test.ts | 20 +++++ src/main/startup/gpu-fallback-marker.ts | 44 +++++++++- src/main/startup/windows-software-gpu.test.ts | 75 ++++++++++++++++- src/main/startup/windows-software-gpu.ts | 49 +++++++++++ 5 files changed, 266 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 6eac2775a7ae..5f9855bfbdc7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,6 +129,7 @@ import { import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { + markGpuFallbackUserNotified, readActiveGpuFallbackMarker, writeGpuFallbackMarker, type GpuFallbackEnvironment, @@ -137,7 +138,11 @@ import { import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' import { applyWindowsSoftwareGpuFallback, - isSoftwareGpuEnvRequested + buildSoftwareGpuFallbackNotice, + isSoftwareGpuEnvRequested, + shouldPresentSoftwareGpuFallbackNotice, + WINDOWS_SOFTWARE_GPU_SWITCHES, + type SoftwareGpuFallbackSource } from './startup/windows-software-gpu' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, @@ -380,6 +385,9 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ }) let gpuFallbackActiveThisLaunch = false let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null +let gpuFallbackSourceThisLaunch: SoftwareGpuFallbackSource | null = null +let gpuFallbackMarkerAlreadyNotified = false +let gpuFallbackNoticePresentedThisSession = false let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -1297,6 +1305,16 @@ function openMainWindow(): BrowserWindow { window.once('ready-to-show', () => { logStartupMilestone('ready-to-show') setImmediate(createSystemTrayDeferred) + // Why: inform once after first paint so software-GPU recovery is not silent (#10097 review). + setImmediate(() => { + void maybePresentSoftwareGpuFallbackNotice(window) + }) + }) + // Why: ready-to-show can stall on broken GPUs; the reveal fallback still emits show. + window.once('show', () => { + setImmediate(() => { + void maybePresentSoftwareGpuFallbackNotice(window) + }) }) const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) trayCreateFallback.unref?.() @@ -1599,15 +1617,77 @@ function maybeApplyGpuFallbackForThisLaunch(): void { appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) } gpuFallbackActiveThisLaunch = true + gpuFallbackSourceThisLaunch = envRequested ? 'env' : 'marker' + gpuFallbackMarkerAlreadyNotified = + typeof marker?.userNotifiedAt === 'number' && Number.isFinite(marker.userNotifiedAt) // Why: with no GPU child left, child-process-gone can't report a GPU fault, so // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { crashesInWindow: marker?.crashesInWindow ?? 0, - source: envRequested ? 'env' : 'marker', + source: gpuFallbackSourceThisLaunch, switches: appliedSwitches.join(',') }) } +// Why: software GPU is a silent recovery path unless we tell the user once (marker sticky / env per session). +async function maybePresentSoftwareGpuFallbackNotice( + targetWindow?: BrowserWindow | null +): Promise { + if ( + !shouldPresentSoftwareGpuFallbackNotice({ + active: gpuFallbackActiveThisLaunch, + source: gpuFallbackSourceThisLaunch, + alreadyPresentedThisSession: gpuFallbackNoticePresentedThisSession, + markerAlreadyNotified: gpuFallbackMarkerAlreadyNotified + }) || + isQuitting + ) { + return + } + const source = gpuFallbackSourceThisLaunch + if (!source) { + return + } + gpuFallbackNoticePresentedThisSession = true + const notice = buildSoftwareGpuFallbackNotice(source) + const options = { + type: 'info' as const, + buttons: ['OK'], + defaultId: 0, + title: notice.title, + message: notice.message, + detail: notice.detail + } + try { + const window = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow + : mainWindow && !mainWindow.isDestroyed() + ? mainWindow + : undefined + if (window) { + await dialog.showMessageBox(window, options) + } else { + await dialog.showMessageBox(options) + } + } catch (error) { + console.warn('[gpu-fallback] failed to present software GPU notice:', error) + // Why: allow a later show/ready-to-show retry if the first dialog attempt failed. + gpuFallbackNoticePresentedThisSession = false + return + } + if (source === 'marker') { + try { + if (markGpuFallbackUserNotified(app.getPath('userData'))) { + gpuFallbackMarkerAlreadyNotified = true + } + } catch (error) { + console.warn('[gpu-fallback] failed to persist notice acknowledgment:', error) + } + } + recordCrashBreadcrumb('gpu_fallback_notice_presented', { source }) +} + // Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. async function handleGpuChildCrash(reason: string, exitCode: number | null): Promise { // Software rendering already active or shutting down: nothing more to do. diff --git a/src/main/startup/gpu-fallback-marker.test.ts b/src/main/startup/gpu-fallback-marker.test.ts index 6aeacf8ec3ed..96705051d842 100644 --- a/src/main/startup/gpu-fallback-marker.test.ts +++ b/src/main/startup/gpu-fallback-marker.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { GPU_FALLBACK_MARKER_FILE, clearGpuFallbackMarker, + markGpuFallbackUserNotified, readActiveGpuFallbackMarker, readGpuFallbackMarker, writeGpuFallbackMarker @@ -114,4 +115,23 @@ describe('gpu-fallback-marker', () => { clearGpuFallbackMarker(userDataPath) expect(readGpuFallbackMarker(userDataPath)).toBeNull() }) + + it('round-trips optional userNotifiedAt and is idempotent', () => { + writeGpuFallbackMarker(userDataPath, { engagedAt: 10, crashesInWindow: 3 }, environment) + expect(readGpuFallbackMarker(userDataPath)?.userNotifiedAt).toBeUndefined() + + expect(markGpuFallbackUserNotified(userDataPath, 999)).toBe(true) + expect(readGpuFallbackMarker(userDataPath)).toMatchObject({ + engagedAt: 10, + crashesInWindow: 3, + userNotifiedAt: 999 + }) + + expect(markGpuFallbackUserNotified(userDataPath, 1_000_000)).toBe(true) + expect(readGpuFallbackMarker(userDataPath)?.userNotifiedAt).toBe(999) + }) + + it('returns false when marking notify without a marker', () => { + expect(markGpuFallbackUserNotified(userDataPath)).toBe(false) + }) }) diff --git a/src/main/startup/gpu-fallback-marker.ts b/src/main/startup/gpu-fallback-marker.ts index e94e654b3ec0..246fea7e4dbe 100644 --- a/src/main/startup/gpu-fallback-marker.ts +++ b/src/main/startup/gpu-fallback-marker.ts @@ -28,12 +28,18 @@ export type GpuFallbackMarker = { appVersion: string electronVersion: string platform: 'win32' + /** When set, the user was already told software GPU is active for this marker. */ + userNotifiedAt?: number } function markerPath(userDataPath: string): string { return join(userDataPath, GPU_FALLBACK_MARKER_FILE) } +function readOptionalFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | null { try { const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf-8')) as Partial< @@ -53,13 +59,15 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | ) { return null } + const userNotifiedAt = readOptionalFiniteNumber(parsed.userNotifiedAt) return { schemeVersion: GPU_FALLBACK_SCHEME_VERSION, engagedAt: parsed.engagedAt, crashesInWindow: parsed.crashesInWindow, appVersion: parsed.appVersion, electronVersion: parsed.electronVersion, - platform: parsed.platform + platform: parsed.platform, + ...(userNotifiedAt !== undefined ? { userNotifiedAt } : {}) } } catch { // missing or corrupt means no fallback requested @@ -69,20 +77,50 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | export function writeGpuFallbackMarker( userDataPath: string, - info: { engagedAt: number; crashesInWindow: number }, + info: { engagedAt: number; crashesInWindow: number; userNotifiedAt?: number }, environment: WindowsGpuFallbackEnvironment ): void { + const userNotifiedAt = readOptionalFiniteNumber(info.userNotifiedAt) const marker: GpuFallbackMarker = { schemeVersion: GPU_FALLBACK_SCHEME_VERSION, engagedAt: info.engagedAt, crashesInWindow: info.crashesInWindow, appVersion: environment.appVersion, electronVersion: environment.electronVersion, - platform: 'win32' + platform: 'win32', + ...(userNotifiedAt !== undefined ? { userNotifiedAt } : {}) } writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) } +/** Persist that the software-GPU notice was shown for the active sticky marker. */ +export function markGpuFallbackUserNotified( + userDataPath: string, + notifiedAt: number = Date.now() +): boolean { + const marker = readGpuFallbackMarker(userDataPath) + if (!marker) { + return false + } + if (readOptionalFiniteNumber(marker.userNotifiedAt) !== undefined) { + return true + } + writeGpuFallbackMarker( + userDataPath, + { + engagedAt: marker.engagedAt, + crashesInWindow: marker.crashesInWindow, + userNotifiedAt: notifiedAt + }, + { + appVersion: marker.appVersion, + electronVersion: marker.electronVersion, + platform: 'win32' + } + ) + return true +} + export function clearGpuFallbackMarker(userDataPath: string): void { try { rmSync(markerPath(userDataPath), { force: true }) diff --git a/src/main/startup/windows-software-gpu.test.ts b/src/main/startup/windows-software-gpu.test.ts index c10f3f4808a2..984203b3a4b2 100644 --- a/src/main/startup/windows-software-gpu.test.ts +++ b/src/main/startup/windows-software-gpu.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { WINDOWS_SOFTWARE_GPU_SWITCHES, applyWindowsSoftwareGpuFallback, - isSoftwareGpuEnvRequested + buildSoftwareGpuFallbackNotice, + isSoftwareGpuEnvRequested, + shouldPresentSoftwareGpuFallbackNotice } from './windows-software-gpu' function createAppMock() { @@ -53,3 +55,74 @@ describe('applyWindowsSoftwareGpuFallback', () => { expect(app.commandLine.appendSwitch).toHaveBeenCalledTimes(WINDOWS_SOFTWARE_GPU_SWITCHES.length) }) }) + +describe('shouldPresentSoftwareGpuFallbackNotice', () => { + it('presents for env source once per session', () => { + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'env', + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(true) + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'env', + alreadyPresentedThisSession: true, + markerAlreadyNotified: false + }) + ).toBe(false) + }) + + it('presents marker fallback only on first activation', () => { + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'marker', + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(true) + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'marker', + alreadyPresentedThisSession: false, + markerAlreadyNotified: true + }) + ).toBe(false) + }) + + it('skips when fallback is inactive or source is missing', () => { + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: false, + source: 'marker', + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(false) + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: null, + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(false) + }) +}) + +describe('buildSoftwareGpuFallbackNotice', () => { + it('explains env opt-in and crash-marker recovery distinctly', () => { + const envNotice = buildSoftwareGpuFallbackNotice('env') + expect(envNotice.title).toMatch(/software gpu/i) + expect(envNotice.detail).toContain('ORCA_SOFTWARE_GPU') + + const markerNotice = buildSoftwareGpuFallbackNotice('marker') + expect(markerNotice.message).toMatch(/unstable hardware gpu/i) + expect(markerNotice.detail).toMatch(/this app version/i) + }) +}) diff --git a/src/main/startup/windows-software-gpu.ts b/src/main/startup/windows-software-gpu.ts index 0066d8940978..7c8aae093a8a 100644 --- a/src/main/startup/windows-software-gpu.ts +++ b/src/main/startup/windows-software-gpu.ts @@ -14,6 +14,15 @@ export type SoftwareGpuCommandLineApp = { } } +/** How software GPU was selected for this process. */ +export type SoftwareGpuFallbackSource = 'env' | 'marker' + +export type SoftwareGpuFallbackNotice = { + title: string + message: string + detail: string +} + /** Chromium switches that form the proven software-render recovery path. */ export const WINDOWS_SOFTWARE_GPU_SWITCHES = [ { name: 'disable-gpu' }, @@ -40,3 +49,43 @@ export function applyWindowsSoftwareGpuFallback(app: SoftwareGpuCommandLineApp): } } } + +/** + * Whether to surface the one-shot software-GPU notice. + * Marker path: first activation only (sticky until build change). + * Env path: once per process — operators opted in explicitly. + */ +export function shouldPresentSoftwareGpuFallbackNotice(args: { + active: boolean + source: SoftwareGpuFallbackSource | null + alreadyPresentedThisSession: boolean + /** Set when the sticky crash-marker already recorded a prior notice. */ + markerAlreadyNotified: boolean +}): boolean { + if (!args.active || args.source === null || args.alreadyPresentedThisSession) { + return false + } + if (args.source === 'marker' && args.markerAlreadyNotified) { + return false + } + return true +} + +export function buildSoftwareGpuFallbackNotice( + source: SoftwareGpuFallbackSource +): SoftwareGpuFallbackNotice { + if (source === 'env') { + return { + title: 'Software GPU is active', + message: 'Orca is using software graphics for stability.', + detail: + 'ORCA_SOFTWARE_GPU is set, so Orca is rendering with ANGLE SwiftShader instead of the hardware GPU. This is slower but more reliable on virtual or remote displays. Unset the variable and restart to try hardware graphics again.' + } + } + return { + title: 'Software GPU is active', + message: 'Orca switched to software graphics after unstable hardware GPU crashes.', + detail: + 'Rendering uses ANGLE SwiftShader so the app can stay usable on this display. It may feel slower. This mode stays on for this app version; updating Orca will try hardware graphics again automatically.' + } +}