diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 1743da5..b8be8f2 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from import { lookup } from 'node:dns/promises'; import { spawn, type ChildProcess } from 'node:child_process'; 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 * as net from 'node:net'; import * as path from 'node:path'; @@ -28,6 +28,306 @@ protocol.registerSchemesAsPrivileged([ { 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 { + const env: Record = { 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): 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. * @@ -532,6 +832,7 @@ function createWindow(): void { icon: resolveWindowIcon(), backgroundColor: '#f6f4f0', autoHideMenuBar: true, + show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), 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 | 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; if (devServer) { void window.loadURL(devServer); @@ -665,6 +989,11 @@ app.whenReady().then(() => { createWindow(); + if (pendingGiveUpDialog) { + dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog); + pendingGiveUpDialog = null; + } + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -673,6 +1002,7 @@ app.whenReady().then(() => { }); app.on('before-quit', () => { + markStartupCleanExit(); if (backend) { backend.kill(); }