diff --git a/apps/desktop/electron/bootstrap-platform.cjs b/apps/desktop/electron/bootstrap-platform.cjs index d6a5e7165fe..2058d90a8f6 100644 --- a/apps/desktop/electron/bootstrap-platform.cjs +++ b/apps/desktop/electron/bootstrap-platform.cjs @@ -32,8 +32,59 @@ function bundledRuntimeImportCheck(platform = process.platform) { return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess' } +const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on']) +const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off']) + +/** + * Decide whether the app is being shown over a remote/forwarded display, where + * Chromium's GPU compositor produces an unstable, flickering surface (it can't + * present accelerated layers cleanly over the wire). Native local Windows/macOS + * sessions composite locally and never hit this, so we only fall back to + * software rendering when a remote display is detected. + * + * Returns a short reason string when GPU acceleration should be disabled, or + * null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection + * both ways (1/true/yes/on → always disable, 0/false/no/off → never disable). + * + * Pure + dependency-free so it can be unit-tested and called before app ready. + */ +function detectRemoteDisplay(options = {}) { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const isWsl = options.isWsl ?? isWslEnvironment(env, platform) + + const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase() + if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)' + if (GPU_OVERRIDE_OFF.has(override)) return null + + // Launched from an SSH session → the display is X11-forwarded or otherwise + // remote. Covers the common `ssh user@box` + GUI-forwarding case. + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session' + + if (platform === 'linux') { + // X11 forwarding sets DISPLAY to ":N" (e.g. "localhost:10.0"); a + // local X server is ":0"/":1" with no host part before the colon. + const display = String(env.DISPLAY || '') + if (display.includes(':') && display.split(':')[0]) { + return `x11-forwarding (DISPLAY=${display})` + } + // WSLg pipes the GUI through an RDP/Wayland bridge — same flicker profile. + if (isWsl) return 'wslg' + } + + if (platform === 'win32') { + // RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is + // "Console". + const sessionName = String(env.SESSIONNAME || '') + if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})` + } + + return null +} + module.exports = { bundledRuntimeImportCheck, + detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } diff --git a/apps/desktop/electron/bootstrap-platform.test.cjs b/apps/desktop/electron/bootstrap-platform.test.cjs index baa8431125a..3a401564cfa 100644 --- a/apps/desktop/electron/bootstrap-platform.test.cjs +++ b/apps/desktop/electron/bootstrap-platform.test.cjs @@ -3,7 +3,12 @@ const fs = require('node:fs') const path = require('node:path') const test = require('node:test') -const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') +const { + bundledRuntimeImportCheck, + detectRemoteDisplay, + isWindowsBinaryPathInWsl, + isWslEnvironment +} = require('./bootstrap-platform.cjs') test('isWslEnvironment detects WSL2 env vars on linux', () => { assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true) @@ -28,6 +33,56 @@ test('bundledRuntimeImportCheck selects platform-specific import checks', () => assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess') }) +test('detectRemoteDisplay keeps GPU on for local sessions', () => { + // Plain local X11, Wayland, native Windows, native macOS — no remote signal. + assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux', isWsl: false }), null) + assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux', isWsl: false }), null) + assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null) + assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null) +}) + +test('detectRemoteDisplay flags SSH sessions on any platform', () => { + assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session') + assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session') + assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session') +}) + +test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => { + assert.match( + String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux', isWsl: false })), + /x11-forwarding/ + ) + assert.match( + String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux', isWsl: false })), + /x11-forwarding/ + ) + assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux', isWsl: false }), null) +}) + +test('detectRemoteDisplay flags WSLg and RDP', () => { + assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux', isWsl: true }), 'wslg') + assert.match( + String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), + /^rdp/ + ) +}) + +test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => { + // Force-on even on a local display. + assert.match( + String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux', isWsl: false })), + /override/ + ) + // Force-off even over SSH (escape hatch when a remote display has working accel). + assert.equal( + detectRemoteDisplay({ + env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, + platform: 'linux' + }), + null + ) +}) + test('packaged electron entrypoints do not require unpackaged npm modules', () => { const electronDir = __dirname const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs'] diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 4e14a928c9f..35e8a942347 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -23,7 +23,7 @@ const net = require('node:net') const path = require('node:path') const { fileURLToPath, pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') -const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') +const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { @@ -73,6 +73,25 @@ const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() const APP_ROOT = app.getAppPath() + +// Remote displays (SSH X11 forwarding, VNC, RDP, WSLg) make Chromium's GPU +// compositor flicker — accelerated layers can't be presented cleanly over the +// wire, so the window flashes during scroll/streaming/animation. Local +// Windows/macOS composite on the GPU and never see it. Fall back to software +// rendering when a remote display is detected; it's rock-steady over the wire +// and the CPU cost is negligible next to the connection's latency. Must run +// before app `ready` — these switches only apply pre-launch. Override with +// HERMES_DESKTOP_DISABLE_GPU (1/true → always disable, 0/false → keep GPU on). +const REMOTE_DISPLAY_REASON = detectRemoteDisplay({ isWsl: IS_WSL }) +if (REMOTE_DISPLAY_REASON) { + app.disableHardwareAcceleration() + // Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch + // with only --disable-gpu: force compositing onto the CPU too. + app.commandLine.appendSwitch('disable-gpu-compositing') + console.log( + `[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker` + ) +} const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Build-time install stamp -- the git ref this .exe was built against.