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:
parent
715c99c688
commit
0814a53cb4
1 changed files with 331 additions and 1 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue