fix(linux): work on X11, Wayland, and Hyprland

- detect the session platform explicitly (Hyprland exports both DISPLAY
  and WAYLAND_DISPLAY) and set ozone-platform before Chromium init
- software rendering by default on Linux: the GPU process segfaults in
  eglCreateWindowSurface on some Mesa/Wayland setups (reproduced on
  Intel Iris Xe under Hyprland), so hardware GL is opt-in via
  KEYNCTR_ENABLE_GPU=1
- startup watchdog + bounded relaunch ladder (platform swap, then GPU
  opt-in) when a launch dies before its window paints; give-up dialog
  lists the escape hatches
- sandbox pre-flight: skip the SUID sandbox when user namespaces are
  restricted (Ubuntu 24.04 AppArmor) instead of failing silently
This commit is contained in:
Avi 2026-09-09 19:58:38 -05:00
commit 0814a53cb4

View file

@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import * as net from 'node:net'; import * as net from 'node:net';
import * as path from 'node:path'; import * as path from 'node:path';
@ -28,6 +28,306 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } }, { scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]); ]);
// -----------------------------------------------------------------------------
// Linux display-server compatibility (X11, Wayland, Hyprland, ...)
//
// Hyprland (and every Wayland session running XWayland) exports BOTH $DISPLAY
// and $WAYLAND_DISPLAY, so the platform must be chosen explicitly before
// Chromium initializes. Everything here runs at module load — before
// `app.whenReady()` — so the switches take effect.
//
// If the first attempt dies before the window ever paints (a common Wayland
// symptom: GPU/dmabuf issues or a broken sandbox), a watchdog relaunches the
// app one rung down a fixed ladder:
//
// 0. as detected, software rendering (safe default)
// 1. the other platform (Wayland -> XWayland, X11 -> Wayland)
// 2. detected platform with the GPU enabled
// 3. the other platform with the GPU enabled
// 4. give up: show an error dialog with the escape hatches below
//
// Escape hatches (environment):
// KEYNCTR_FORCE_X11=1 always use X11/XWayland
// KEYNCTR_FORCE_WAYLAND=1 always use native Wayland
// KEYNCTR_ENABLE_GPU=1 use hardware-accelerated rendering
// KEYNCTR_DISABLE_GPU=1 force software rendering (the default)
// KEYNCTR_NO_RELAUNCH=1 disable the fallback relauncher
// -----------------------------------------------------------------------------
/** How long a launch has to prove it works before the watchdog intervenes. */
const LAUNCH_PROVE_MS = 8_000;
/** The max ladder distance: a marker newer than this means the last launch crashed early. */
const CRASH_WINDOW_MS = 45_000;
function detectSessionPlatform(): 'wayland' | 'x11' {
if (process.env.KEYNCTR_FORCE_X11) {
return 'x11';
}
if (process.env.KEYNCTR_FORCE_WAYLAND) {
return 'wayland';
}
const sessionType = (process.env.XDG_SESSION_TYPE ?? '').toLowerCase();
if (sessionType === 'wayland' || process.env.WAYLAND_DISPLAY) {
return 'wayland';
}
return 'x11';
}
const sessionPlatform = detectSessionPlatform();
const fallbackStep = Number.parseInt(process.env.KEYNCTR_FALLBACK_STEP ?? '0', 10);
/**
* Per-user marker recording the launch currently in flight. If a previous
* process left one behind and it is recent, that launch died before its
* window ever painted — so this process continues the fallback ladder.
*/
function startupMarkerPath(): string {
return path.join(app.getPath('temp'), 'keynctr-startup.json');
}
interface StartupMarker {
step: number;
platform: string;
startedAt: number;
/** Set when the app shut down on purpose (not a crash before first paint). */
clean?: boolean;
}
function readStartupMarker(): StartupMarker | null {
try {
const parsed = JSON.parse(readFileSync(startupMarkerPath(), 'utf8')) as StartupMarker;
if (typeof parsed.step === 'number' && typeof parsed.startedAt === 'number') {
return parsed;
}
} catch {
// No marker (or unreadable): nothing to learn.
}
return null;
}
function writeStartupMarker(step: number): void {
try {
writeFileSync(
startupMarkerPath(),
JSON.stringify({ step, platform: sessionPlatform, startedAt: Date.now() }),
);
} catch {
// Marker is best-effort only.
}
}
function clearStartupMarker(): void {
try {
rmSync(startupMarkerPath(), { force: true });
} catch {
// Best-effort.
}
}
/**
* Stamp the marker as a clean exit so the next launch does not mistake an
* intentional quit (e.g. closing the window a few seconds after it opened)
* for a crash before first paint.
*/
function markStartupCleanExit(): void {
const marker = readStartupMarker();
if (marker && !marker.clean) {
try {
writeFileSync(startupMarkerPath(), JSON.stringify({ ...marker, clean: true }));
} catch {
// Best-effort.
}
}
}
/** Environment for fallback ladder rung `step` (0 keeps the detected setup). */
function envForFallbackStep(step: number): Record<string, string> {
const env: Record<string, string> = { KEYNCTR_FALLBACK_STEP: String(step) };
const other = sessionPlatform === 'wayland' ? 'x11' : 'wayland';
switch (step) {
case 1:
if (other === 'x11') {
env.KEYNCTR_FORCE_X11 = '1';
} else {
env.KEYNCTR_FORCE_WAYLAND = '1';
}
break;
case 2:
if (sessionPlatform === 'x11') {
env.KEYNCTR_FORCE_WAYLAND = '1'; // X11 failed: try native Wayland (still software GL)
} else {
env.KEYNCTR_ENABLE_GPU = '1'; // Wayland failed: retry Wayland with hardware GL
env.KEYNCTR_DISABLE_GPU = ''; // clear any user override that would block the retry
}
break;
case 3:
if (other === 'x11') {
env.KEYNCTR_FORCE_X11 = '1';
} else {
env.KEYNCTR_FORCE_WAYLAND = '1';
}
env.KEYNCTR_ENABLE_GPU = '1';
env.KEYNCTR_DISABLE_GPU = '';
break;
}
return env;
}
function describeFallbackStep(step: number): string {
const other = sessionPlatform === 'wayland' ? 'XWayland (X11)' : 'native Wayland';
switch (step) {
case 1:
return `${other}, software rendering`;
case 2:
return sessionPlatform === 'wayland'
? `${sessionPlatform} with hardware acceleration`
: 'native Wayland, software rendering';
case 3:
return `${other} with hardware acceleration`;
default:
return 'default settings';
}
}
/** Relaunch this executable with extra environment variables, then quit. */
function relaunchLinux(extraEnv: Record<string, string>): void {
// On AppImage, process.execPath is the temporary FUSE mount, which is torn
// down when this process exits — relaunch the original file instead.
const target = process.env.APPIMAGE || process.execPath;
try {
const child = spawn(target, process.argv.slice(1), {
env: { ...process.env, ...extraEnv },
detached: true,
stdio: 'ignore',
});
child.unref();
app.exit(0);
} catch (err) {
console.error('[linux] relaunch failed:', err);
}
}
/** Set when the fallback ladder is exhausted: shown once Electron is ready. */
let pendingGiveUpDialog: string | null = null;
/**
* Decide, at startup, whether the previous launch crashed before painting a
* window and, if so, relaunch one rung further down the fallback ladder.
* Called once at module load, before the Ozone switches below are applied.
*/
function evaluateLinuxStartup(): void {
if (process.platform !== 'linux' || process.env.KEYNCTR_NO_RELAUNCH) {
clearStartupMarker();
return;
}
const marker = readStartupMarker();
const crashedEarly =
marker !== null && !marker.clean && Date.now() - marker.startedAt < CRASH_WINDOW_MS;
if (fallbackStep > 0) {
// We are already a relaunch: record this attempt (cleared once the window
// paints and stays up). Never cascade from here — each crash advances the
// ladder exactly one rung on the NEXT launch.
if (marker && crashedEarly) {
console.warn(
`[linux] previous launch (${describeFallbackStep(marker.step)}) exited before its ` +
'window was ready.',
);
}
writeStartupMarker(fallbackStep);
return;
}
if (marker && crashedEarly) {
const nextStep = marker.step + 1;
if (nextStep <= 3) {
console.warn(
`[linux] previous launch (${describeFallbackStep(marker.step)}) exited before its ` +
`window was ready; retrying with ${describeFallbackStep(nextStep)}.`,
);
relaunchLinux(envForFallbackStep(nextStep));
return; // relaunchLinux exits the process.
}
// Ladder exhausted. Stay on the safest default (detected platform,
// software rendering) and tell the user about the escape hatches instead
// of relaunching forever.
delete process.env.KEYNCTR_ENABLE_GPU;
pendingGiveUpDialog =
'Keynctr failed to start with every display configuration (default, ' +
`${describeFallbackStep(1)}, ${describeFallbackStep(2)}, ${describeFallbackStep(3)}).\n\n` +
'This attempt uses the most compatible mode. If it still fails, force a ' +
'configuration from a terminal, e.g.:\n' +
' KEYNCTR_FORCE_X11=1 keynctr (XWayland)\n' +
' KEYNCTR_FORCE_WAYLAND=1 keynctr (native Wayland)\n' +
' KEYNCTR_ENABLE_GPU=1 keynctr (hardware acceleration)\n';
}
// Fresh launch: record the attempt; cleared once the window proves itself.
clearStartupMarker();
writeStartupMarker(0);
}
if (process.platform === 'linux') {
// Runs FIRST so the env overrides below (and the GPU switch) see any
// force-flags this process just adopted from the fallback ladder.
evaluateLinuxStartup();
if (detectSessionPlatform() === 'wayland') {
app.commandLine.appendSwitch('ozone-platform', 'wayland');
} else {
app.commandLine.appendSwitch('ozone-platform', 'x11');
}
// Chromium refuses to sandbox when running as root.
if (typeof process.getuid === 'function' && process.getuid() === 0) {
app.commandLine.appendSwitch('no-sandbox');
}
// SUID sandbox pre-flight: if the helper exists but is not setuid-root AND
// unprivileged user namespaces are blocked (Ubuntu 24.04 AppArmor, hardened
// kernels, some containers), Chromium aborts before any window appears.
// Start without the sandbox instead of refusing to start.
if (app.isPackaged) {
try {
const helper = path.join(path.dirname(process.execPath), 'chrome-sandbox');
if (existsSync(helper) && (statSync(helper).mode & 0o4000) === 0) {
const procFlag = (file: string, blockedValue: string): boolean => {
try {
return readFileSync(file, 'utf8').trim() === blockedValue;
} catch {
return false; // Kernel without the knob: assume allowed.
}
};
const cloneBlocked = procFlag('/proc/sys/kernel/unprivileged_userns_clone', '0');
const apparmorRestricted = procFlag(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
'1',
);
if (cloneBlocked || apparmorRestricted) {
console.warn(
'[linux] chrome-sandbox is not setuid and unprivileged user namespaces are ' +
'restricted; starting with the sandbox disabled.',
);
app.commandLine.appendSwitch('no-sandbox');
}
}
} catch (err) {
console.error('[linux] sandbox pre-flight failed:', err);
}
}
// Chromium's hardware GL path is unreliable under Wayland compositors on
// some Mesa/EGL setups (observed: the GPU process segfaults inside
// eglCreateWindowSurface on Intel Iris Xe under Hyprland, so the window
// never paints). Software rendering costs nothing noticeable for this app,
// so hardware acceleration is off by default on Linux; set
// KEYNCTR_ENABLE_GPU=1 to opt back in.
const gpuEnabled = Boolean(process.env.KEYNCTR_ENABLE_GPU) && !process.env.KEYNCTR_DISABLE_GPU;
if (!gpuEnabled) {
app.disableHardwareAcceleration();
app.commandLine.appendSwitch('disable-gpu-compositing');
}
}
/** /**
* Content-Security-Policy applied to every page this app loads. * Content-Security-Policy applied to every page this app loads.
* *
@ -532,6 +832,7 @@ function createWindow(): void {
icon: resolveWindowIcon(), icon: resolveWindowIcon(),
backgroundColor: '#f6f4f0', backgroundColor: '#f6f4f0',
autoHideMenuBar: true, autoHideMenuBar: true,
show: false,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,
@ -539,6 +840,29 @@ function createWindow(): void {
}, },
}); });
// Always reveal the window once it has painted. On Linux the startup
// watchdog additionally waits LAUNCH_PROVE_MS before clearing the marker:
// if the process dies before that, the next launch advances the fallback
// ladder one rung.
window.once('ready-to-show', () => {
window.show();
});
if (process.platform === 'linux' && !process.env.KEYNCTR_NO_RELAUNCH) {
let proveTimer: ReturnType<typeof setTimeout> | null = null;
window.once('ready-to-show', () => {
proveTimer = setTimeout(() => {
proveTimer = null;
clearStartupMarker();
}, LAUNCH_PROVE_MS);
});
window.webContents.on('render-process-gone', () => {
if (proveTimer) {
clearTimeout(proveTimer);
proveTimer = null;
}
});
}
const devServer = process.env.NOSTR_GUI_DEV_URL; const devServer = process.env.NOSTR_GUI_DEV_URL;
if (devServer) { if (devServer) {
void window.loadURL(devServer); void window.loadURL(devServer);
@ -665,6 +989,11 @@ app.whenReady().then(() => {
createWindow(); createWindow();
if (pendingGiveUpDialog) {
dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog);
pendingGiveUpDialog = null;
}
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
@ -673,6 +1002,7 @@ app.whenReady().then(() => {
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
markStartupCleanExit();
if (backend) { if (backend) {
backend.kill(); backend.kill();
} }