- launch-keynctr.sh: builds renderer/electron main if missing, exports KEYNCTR_ENABLE_GPU=1 (software rendering dies 'GPU process isn't usable' on this Hyprland box), execs bundled electron with --class=keynectr for WM_CLASS grouping. - keynctr.desktop installed at ~/.local/share/applications/ (validated, icon = public/icon.png, categories Utility, StartupWMClass keynectr). - requestSingleInstanceLock: second launch focuses the existing window (app.exit(0) in the doomed instance) instead of a duplicate shell fighting the vault. Verified via gtk-launch: 'keynectr | Keynctr' window maps; second gtk-launch keeps exactly 1 window.
1126 lines
37 KiB
TypeScript
1126 lines
37 KiB
TypeScript
import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from 'electron';
|
|
import { lookup } from 'node:dns/promises';
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { randomBytes } from 'node:crypto';
|
|
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';
|
|
|
|
const MIME: Record<string, string> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.ico': 'image/x-icon',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.json': 'application/json',
|
|
'.map': 'application/json',
|
|
};
|
|
|
|
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<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.
|
|
*
|
|
* The production policy forbids inline scripts entirely (the Vite bundle is
|
|
* external), so an injected `<script>` in rendered content cannot execute. The
|
|
* dev-server policy keeps `'unsafe-inline'` because Vite's React-refresh
|
|
* preamble is an inline script, but still pins network access to localhost
|
|
* (HMR websocket included).
|
|
*/
|
|
const CSP_PROD =
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
"connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " +
|
|
"base-uri 'none'; form-action 'none'";
|
|
const CSP_DEV =
|
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; " +
|
|
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
|
|
/** The CSP for a URL this window may load, or `null` for anywhere else. */
|
|
function cspForUrl(url: string): string | null {
|
|
if (url.startsWith('app://')) {
|
|
return CSP_PROD;
|
|
}
|
|
const devServer = process.env.NOSTR_GUI_DEV_URL;
|
|
if (devServer) {
|
|
try {
|
|
if (new URL(url).origin === new URL(devServer).origin) {
|
|
return CSP_DEV;
|
|
}
|
|
} catch {
|
|
// Fall through: not a URL we recognise.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface PendingRequest {
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason: Error) => void;
|
|
}
|
|
|
|
let backend: ChildProcess | null = null;
|
|
let backendStarted = false;
|
|
const pending = new Map<number, PendingRequest>();
|
|
let nextId = 1;
|
|
|
|
function resolveBackendPath(): string {
|
|
if (app.isPackaged) {
|
|
return path.join(process.resourcesPath, 'keynectr');
|
|
}
|
|
// Development: the crate builds to <project>/target/release.
|
|
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
|
|
}
|
|
|
|
/**
|
|
* The app icon ships inside the bundle: Vite copies `public/icon.png` into
|
|
* `dist/`, so the packaged app finds it next to the loaded page. In dev the
|
|
* Vite project's `public/` folder is the same file.
|
|
*/
|
|
function resolveWindowIcon(): string {
|
|
const base = app.isPackaged
|
|
? path.join(app.getAppPath(), 'dist')
|
|
: path.join(app.getAppPath(), 'public');
|
|
return path.join(base, 'icon.png');
|
|
}
|
|
|
|
/**
|
|
* Well-known per-user tool dirs appended to the inherited PATH so npm/cargo
|
|
* resolve when Electron launches us from a desktop launcher (mirrors the
|
|
* backend's augmented_path() in src/updates.rs).
|
|
*/
|
|
function augmentedPath(): string {
|
|
const home = process.env.HOME ?? '';
|
|
const extra = [
|
|
`${home}/.cargo/bin`,
|
|
`${home}/.local/bin`,
|
|
`${home}/.mise/shims`,
|
|
`${home}/.asdf/shims`,
|
|
'/usr/local/bin',
|
|
];
|
|
const inherited = process.env.PATH ?? '';
|
|
return [...inherited.split(':').filter(Boolean), ...extra].join(':');
|
|
}
|
|
|
|
/** Run a build command to completion, resolving with its combined output. */
|
|
function runBuild(
|
|
cwd: string,
|
|
program: string,
|
|
args: string[],
|
|
): Promise<{ ok: boolean; output: string }> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(program, args, {
|
|
cwd,
|
|
env: { ...process.env, PATH: augmentedPath() },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let output = '';
|
|
child.stdout?.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
child.stderr?.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
child.on('error', (err) => resolve({ ok: false, output: `${program}: ${err.message}` }));
|
|
child.on('close', (code) => resolve({ ok: code === 0, output }));
|
|
});
|
|
}
|
|
|
|
let selfUpdateInFlight: Promise<{ reloaded: boolean; note: string }> | null = null;
|
|
|
|
/**
|
|
* Rebuild the app from its source checkout and hot-swap the running parts:
|
|
* fresh `dist/` + a freshly spawned backend, then reload every window. The
|
|
* Electron shell keeps running, so the user does not restart the app.
|
|
* (A change to this main-process file itself still needs a manual relaunch.)
|
|
*/
|
|
async function selfUpdate(): Promise<{ reloaded: boolean; note: string }> {
|
|
if (selfUpdateInFlight) return selfUpdateInFlight;
|
|
selfUpdateInFlight = (async () => {
|
|
if (app.isPackaged) {
|
|
throw new Error('This build is packaged; updates are applied by replacing the app bundle.');
|
|
}
|
|
const frontendDir = app.getAppPath();
|
|
const projectRoot = path.join(frontendDir, '..');
|
|
|
|
const npmBuild = await runBuild(frontendDir, 'npm', ['run', 'build']);
|
|
if (!npmBuild.ok) {
|
|
throw new Error(`Frontend build failed:\n${npmBuild.output.slice(-2000)}`);
|
|
}
|
|
const cargoBuild = await runBuild(projectRoot, 'cargo', ['build', '--release']);
|
|
if (!cargoBuild.ok) {
|
|
throw new Error(`Rust build failed:\n${cargoBuild.output.slice(-2000)}`);
|
|
}
|
|
|
|
// Swap the backend: kill the old child; the next request spawns the new
|
|
// binary. startBackend() re-checks exitCode, so resetting the flag is
|
|
// enough once the process is actually gone.
|
|
if (backend && backend.exitCode === null) {
|
|
backend.kill();
|
|
await new Promise<void>((resolve) => {
|
|
if (!backend) return resolve();
|
|
backend.once('exit', () => resolve());
|
|
setTimeout(resolve, 3000);
|
|
});
|
|
}
|
|
backend = null;
|
|
backendStarted = false;
|
|
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
window.webContents.reloadIgnoringCache();
|
|
}
|
|
return { reloaded: true, note: 'Rebuilt and reloaded. The app stayed open.' };
|
|
})().finally(() => {
|
|
selfUpdateInFlight = null;
|
|
});
|
|
return selfUpdateInFlight;
|
|
}
|
|
|
|
function startBackend(): void {
|
|
if (backendStarted && backend && backend.exitCode === null) {
|
|
return;
|
|
}
|
|
backendStarted = true;
|
|
|
|
const bin = resolveBackendPath();
|
|
backend = spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
|
|
const stdout = backend.stdout;
|
|
const stderr = backend.stderr;
|
|
const stdin = backend.stdin;
|
|
if (!stdout || !stderr || !stdin) {
|
|
console.error('[backend] failed to capture backend streams.');
|
|
return;
|
|
}
|
|
|
|
const reader = createInterface({ input: stdout });
|
|
reader.on('line', (line) => {
|
|
let envelope: { id?: number };
|
|
try {
|
|
envelope = JSON.parse(line);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (typeof envelope.id === 'number') {
|
|
const entry = pending.get(envelope.id);
|
|
if (entry) {
|
|
pending.delete(envelope.id);
|
|
entry.resolve(envelope);
|
|
}
|
|
}
|
|
});
|
|
|
|
stderr.on('data', (chunk: Buffer) => {
|
|
// Backend diagnostics go to stderr only; never secrets, never forwarded.
|
|
console.error('[backend]', chunk.toString().trim());
|
|
});
|
|
|
|
backend.on('error', (err) => {
|
|
console.error('[backend] failed to start:', err.message);
|
|
});
|
|
|
|
backend.on('exit', (code) => {
|
|
const error = new Error(
|
|
`The Rust backend exited unexpectedly${code === null ? '' : ` (code ${code})`}.`,
|
|
);
|
|
for (const [, entry] of pending) {
|
|
entry.reject(error);
|
|
}
|
|
pending.clear();
|
|
backend = null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upper bound for one backend round-trip. Generous on purpose: a publish can
|
|
* wait on relays and dependency updates run npm/cargo commands that can take
|
|
* minutes on cold caches. A hung backend still gets reaped instead of leaking
|
|
* promises forever.
|
|
*/
|
|
const BACKEND_TIMEOUT_MS = 300_000;
|
|
|
|
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
startBackend();
|
|
const stdin = backend?.stdin;
|
|
if (!backend || !stdin || stdin.destroyed) {
|
|
throw new Error('The Rust backend is not available.');
|
|
}
|
|
const id = nextId++;
|
|
const payload = { id, method, ...params };
|
|
let timer!: ReturnType<typeof setTimeout>;
|
|
const response = new Promise<unknown>((resolve, reject) => {
|
|
pending.set(id, {
|
|
resolve,
|
|
reject,
|
|
});
|
|
timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error('The background service did not respond in time.'));
|
|
}, BACKEND_TIMEOUT_MS);
|
|
});
|
|
stdin.write(`${JSON.stringify(payload)}\n`);
|
|
return response.finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
/**
|
|
* Methods the renderer is allowed to invoke, enforced in the main process so a
|
|
* compromised page cannot invent new backend calls. Everything else is
|
|
* rejected. Sensitive methods are listed because their screens need them; they
|
|
* remain gated by the vault password on the backend side.
|
|
*/
|
|
const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
|
// Handled natively by Electron main (dialogs, HTTP).
|
|
'pick_image',
|
|
'link_preview',
|
|
'upload_image',
|
|
// Forwarded to the Rust backend over stdio.
|
|
'init',
|
|
'get_state',
|
|
'create_profile',
|
|
'import_profile',
|
|
'select_profile',
|
|
'publish_profile_metadata',
|
|
'set_profile_picture',
|
|
'rename_profile',
|
|
'set_nip05',
|
|
'delete_profile',
|
|
'undo_delete',
|
|
'publish_note',
|
|
'feed_get',
|
|
'relay_add',
|
|
'relay_remove',
|
|
'relay_set_enabled',
|
|
'relay_test',
|
|
'settings_update',
|
|
'update_check',
|
|
'update_apply',
|
|
'backup_now',
|
|
'set_vault_password',
|
|
'unlock_vault',
|
|
'lock_vault',
|
|
'remove_vault_password',
|
|
'reveal_secret_key',
|
|
'export_secret_key',
|
|
// Legacy bunker
|
|
'signer_connect',
|
|
'signer_disconnect',
|
|
'signer_status',
|
|
'signer_approve',
|
|
'signer_grants_list',
|
|
'signer_grant_revoke',
|
|
// New signer modes (default: nip46_client most secure)
|
|
'signer_mode_get',
|
|
'signer_mode_set',
|
|
'embedded_signer_status',
|
|
'embedded_signer_approve',
|
|
'nip46_connect',
|
|
'nip46_pair_start',
|
|
'nip46_disconnect',
|
|
'nip46_status',
|
|
'nip46_approve',
|
|
// Sidecar (local isolated signer, planned)
|
|
'sidecar_connect',
|
|
'sidecar_disconnect',
|
|
'sidecar_status',
|
|
'sidecar_approve',
|
|
]);
|
|
|
|
/** True when `method` may be dispatched. Unknown methods never reach the backend. */
|
|
function isAllowedMethod(method: unknown): method is string {
|
|
return typeof method === 'string' && RENDERER_METHODS.has(method);
|
|
}
|
|
|
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'];
|
|
|
|
/** A file chosen through the native dialog, known only to the main process. */
|
|
interface PickedFile {
|
|
path: string;
|
|
name: string;
|
|
mime: string;
|
|
}
|
|
|
|
/**
|
|
* One-time tokens handed to the renderer in place of filesystem paths.
|
|
* `upload_image` accepts only these, so a compromised page can never point an
|
|
* upload at an arbitrary local file — only at files the user explicitly picked,
|
|
* and each exactly once.
|
|
*/
|
|
const pickedTokens = new Map<string, PickedFile>();
|
|
|
|
/** Mint a single-use upload token for a freshly picked file. */
|
|
function issueToken(file: PickedFile): string {
|
|
const token = randomBytes(16).toString('hex');
|
|
pickedTokens.set(token, file);
|
|
return token;
|
|
}
|
|
|
|
/** How long to wait for a link-preview page before giving up. */
|
|
const LINK_PREVIEW_TIMEOUT_MS = 10_000;
|
|
/** Cap on how much HTML we parse for meta tags. */
|
|
const LINK_PREVIEW_MAX_BYTES = 1_000_000;
|
|
|
|
/** Whether an IPv4 address is loopback, private, or otherwise non-routable. */
|
|
function ipv4IsPrivate(ip: string): boolean {
|
|
const parts = ip.split('.').map(Number);
|
|
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
|
return true; // Malformed: treat as unsafe.
|
|
}
|
|
const [a, b] = parts;
|
|
return (
|
|
a === 0 ||
|
|
a === 10 ||
|
|
a === 127 ||
|
|
(a === 100 && b >= 64 && b <= 127) ||
|
|
(a === 169 && b === 254) ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168)
|
|
);
|
|
}
|
|
|
|
/** Whether an IPv6 address is loopback, link-local, unique-local, or v4-mapped private. */
|
|
function ipv6IsPrivate(ip: string): boolean {
|
|
const addr = ip.toLowerCase();
|
|
if (addr === '::' || addr === '::1') {
|
|
return true;
|
|
}
|
|
const mapped = addr.startsWith('::ffff:') ? addr.slice(7) : null;
|
|
if (mapped) {
|
|
return net.isIPv4(mapped) ? ipv4IsPrivate(mapped) : true;
|
|
}
|
|
// fc00::/7 (unique local) and fe80::/10 (link local).
|
|
return /^f[cd]/.test(addr) || /^fe[89ab]/.test(addr);
|
|
}
|
|
|
|
/** Whether `ip` points at the local machine or a private network. */
|
|
function isPrivateAddress(ip: string): boolean {
|
|
return net.isIPv4(ip) ? ipv4IsPrivate(ip) : ipv6IsPrivate(ip);
|
|
}
|
|
|
|
/**
|
|
* Resolve `url`'s host and refuse loopback/private targets, so a crafted note
|
|
* link cannot make the app probe the user's localhost or LAN ("SSRF"). Hosts
|
|
* are checked at their resolved addresses, not just by name.
|
|
*/
|
|
async function resolvesToPrivateAddress(url: URL): Promise<boolean> {
|
|
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
|
return true;
|
|
}
|
|
let addresses: string[];
|
|
if (net.isIP(host)) {
|
|
addresses = [host];
|
|
} else {
|
|
try {
|
|
addresses = (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
|
|
} catch {
|
|
return true; // Unresolvable: nothing useful to preview anyway.
|
|
}
|
|
}
|
|
return addresses.some(isPrivateAddress);
|
|
}
|
|
|
|
/** A best-effort MIME type derived from the file name. */
|
|
function mimeForPath(filePath: string): string {
|
|
switch (path.extname(filePath).toLowerCase()) {
|
|
case '.png':
|
|
return 'image/png';
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
return 'image/jpeg';
|
|
case '.gif':
|
|
return 'image/gif';
|
|
case '.webp':
|
|
return 'image/webp';
|
|
case '.avif':
|
|
return 'image/avif';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
/** Show an open dialog and return one-time tokens for the chosen images. */
|
|
async function pickImage(): Promise<{ token: string; name: string; mime: string }[]> {
|
|
const result = await dialog.showOpenDialog({
|
|
title: 'Attach image(s)',
|
|
filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }],
|
|
properties: ['openFile', 'multiSelections'],
|
|
});
|
|
if (result.canceled) {
|
|
return [];
|
|
}
|
|
return result.filePaths.map((filePath) => {
|
|
const file: PickedFile = {
|
|
path: filePath,
|
|
name: path.basename(filePath),
|
|
mime: mimeForPath(filePath),
|
|
};
|
|
return { token: issueToken(file), name: file.name, mime: file.mime };
|
|
});
|
|
}
|
|
|
|
/** Upload a picked image to nostr.build and return the public URL + MIME type. */
|
|
async function uploadImage(file: PickedFile): Promise<{ url: string; mime: string }> {
|
|
const uploadUrl = 'https://nostr.build/api/v2/upload/files';
|
|
const data = readFileSync(file.path);
|
|
const mime = file.mime;
|
|
|
|
// nostr.build requires a NIP-98 auth token signed with the active profile's key.
|
|
const authEnvelope = (await backendRequest('upload_auth', {
|
|
url: uploadUrl,
|
|
http_method: 'POST',
|
|
})) as { status: string; data?: { authorization?: string }; message?: string };
|
|
if (authEnvelope.status !== 'ok' || !authEnvelope.data?.authorization) {
|
|
throw new Error(authEnvelope.message ?? 'Could not authorize the upload.');
|
|
}
|
|
|
|
const form = new FormData();
|
|
form.append(
|
|
'fileToUpload',
|
|
new Blob([data], { type: mime }),
|
|
path.basename(file.name) || 'image',
|
|
);
|
|
const response = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: { authorization: authEnvelope.data.authorization },
|
|
body: form,
|
|
});
|
|
const payload = (await response.json().catch(() => ({}))) as {
|
|
status?: string;
|
|
message?: string;
|
|
data?: { url?: string; mime?: string }[];
|
|
};
|
|
const uploaded = payload.data?.[0];
|
|
if (!response.ok || payload.status !== 'success' || !uploaded?.url) {
|
|
throw new Error(
|
|
payload.message || `The image host rejected the upload (HTTP ${response.status}).`,
|
|
);
|
|
}
|
|
return { url: uploaded.url, mime: uploaded.mime ?? mime };
|
|
}
|
|
|
|
interface LinkPreview {
|
|
url: string;
|
|
title: string;
|
|
description: string | null;
|
|
image: string | null;
|
|
site_name: string | null;
|
|
}
|
|
|
|
function decodeHtmlEntities(value: string): string {
|
|
return value
|
|
.replace(/&/gi, '&')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/ /gi, ' ');
|
|
}
|
|
|
|
/** Parse `<meta>` tags into a lower-cased property/name -> content map. */
|
|
function parseMetaTags(html: string): Record<string, string> {
|
|
const tags: Record<string, string> = {};
|
|
const attr = (tag: string, name: string): string | null => {
|
|
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i'));
|
|
return match ? match[2] : null;
|
|
};
|
|
for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
|
|
const key = attr(match[0], 'property') ?? attr(match[0], 'name');
|
|
const value = attr(match[0], 'content');
|
|
if (key && value && !(key.toLowerCase() in tags)) {
|
|
tags[key.toLowerCase()] = decodeHtmlEntities(value.trim());
|
|
}
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
/** Fetch a web page and pull an OpenGraph/Twitter card for the preview. */
|
|
async function fetchLinkPreview(rawUrl: string): Promise<LinkPreview | null> {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
return null;
|
|
}
|
|
if (await resolvesToPrivateAddress(url)) {
|
|
return null;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), LINK_PREVIEW_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url.toString(), {
|
|
headers: {
|
|
accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
|
|
'user-agent':
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) NostrFeedManager/0.1.0',
|
|
},
|
|
redirect: 'follow',
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
if (!contentType.toLowerCase().includes('text/html')) {
|
|
return null;
|
|
}
|
|
const html = (await response.text()).slice(0, LINK_PREVIEW_MAX_BYTES);
|
|
const meta = parseMetaTags(html);
|
|
const base = new URL(response.url);
|
|
|
|
const resolveUrl = (value: string | undefined): string | null => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
try {
|
|
return new URL(value, base).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const image = resolveUrl(meta['og:image'] ?? meta['twitter:image']);
|
|
const fallbackTitle = extractTitle(html);
|
|
const title =
|
|
decodeHtmlEntities(meta['og:title'] ?? meta['twitter:title']) ||
|
|
(fallbackTitle ? decodeHtmlEntities(fallbackTitle) : base.hostname);
|
|
const description =
|
|
decodeHtmlEntities(meta['og:description'] ?? meta['twitter:description'] ?? '') || null;
|
|
const site_name = decodeHtmlEntities(meta['og:site_name'] ?? '') || null;
|
|
|
|
if (!image && !title) {
|
|
return null;
|
|
}
|
|
return { url: base.toString(), title, description, image, site_name };
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function extractTitle(html: string): string {
|
|
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
return match ? decodeHtmlEntities(match[1].trim()) : '';
|
|
}
|
|
|
|
function createWindow(): void {
|
|
const window = new BrowserWindow({
|
|
width: 1160,
|
|
height: 760,
|
|
minWidth: 920,
|
|
minHeight: 640,
|
|
title: 'Keynctr',
|
|
icon: resolveWindowIcon(),
|
|
backgroundColor: '#f6f4f0',
|
|
autoHideMenuBar: true,
|
|
show: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
|
|
// 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;
|
|
if (devServer) {
|
|
void window.loadURL(devServer);
|
|
} else {
|
|
void window.loadURL('app://nfm/index.html');
|
|
}
|
|
|
|
// Stamp every top-level document with the matching CSP.
|
|
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
|
if (details.resourceType !== 'mainFrame') {
|
|
callback({});
|
|
return;
|
|
}
|
|
const csp = cspForUrl(details.url);
|
|
if (!csp) {
|
|
callback({});
|
|
return;
|
|
}
|
|
callback({
|
|
responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [csp] },
|
|
});
|
|
});
|
|
|
|
// The app window never navigates away from its own origin; external links
|
|
// open in the system browser instead. Deny everything unrecognised outright.
|
|
window.webContents.on('will-navigate', (event, url) => {
|
|
if (cspForUrl(url) === null) {
|
|
event.preventDefault();
|
|
if (/^https?:/i.test(url)) {
|
|
void shell.openExternal(url).catch(() => {});
|
|
}
|
|
}
|
|
});
|
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (/^https?:/i.test(url)) {
|
|
void shell.openExternal(url).catch(() => {});
|
|
}
|
|
return { action: 'deny' };
|
|
});
|
|
}
|
|
|
|
// Wayland has no per-window icons: the taskbar resolves the running app's id
|
|
// through an installed .desktop file instead (see
|
|
// scripts/install-desktop-entry.sh). Pin the identity before `ready`.
|
|
if (process.platform === 'linux') {
|
|
app.setDesktopName('keynectr.desktop');
|
|
}
|
|
|
|
// Launched from the applications list a second time? Focus the existing
|
|
// window instead of starting a duplicate app (two Electron shells would
|
|
// each spawn their own backend and fight over the vault file).
|
|
const gotInstanceLock = app.requestSingleInstanceLock();
|
|
if (!gotInstanceLock) {
|
|
// Hard exit: app.quit() is async and would let the rest of this module
|
|
// (whenReady → backend spawn) run in the doomed second instance.
|
|
app.exit(0);
|
|
} else {
|
|
app.on('second-instance', () => {
|
|
const [window] = BrowserWindow.getAllWindows();
|
|
if (window) {
|
|
if (window.isMinimized()) window.restore();
|
|
window.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
protocol.handle('app', (request) => {
|
|
const { pathname } = new URL(request.url);
|
|
let relative = decodeURIComponent(pathname);
|
|
if (relative.endsWith('/')) {
|
|
relative += 'index.html';
|
|
}
|
|
const safe = path
|
|
.normalize(relative)
|
|
.replace(/^(\.\.[/\\])+/, '')
|
|
.replace(/^[/\\]+/, '');
|
|
const filePath = path.join(app.getAppPath(), 'dist', safe);
|
|
try {
|
|
const data = readFileSync(filePath);
|
|
const type = MIME[path.extname(filePath)] ?? 'application/octet-stream';
|
|
return new Response(data, { headers: { 'content-type': type } });
|
|
} catch {
|
|
return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
}
|
|
});
|
|
|
|
ipcMain.handle(
|
|
'backend:request',
|
|
async (_event, payload: { method: string; params?: Record<string, unknown> }) => {
|
|
if (!isAllowedMethod(payload?.method)) {
|
|
console.warn('[backend] rejected renderer method:', String(payload?.method));
|
|
return {
|
|
status: 'error',
|
|
code: 'unknown_method',
|
|
message: 'That operation is not permitted.',
|
|
details: null,
|
|
};
|
|
}
|
|
const params = payload.params ?? {};
|
|
// Media and network tasks are handled here (Electron) rather than the
|
|
// Rust backend: they need a native file dialog, the hosting upload, and
|
|
// fetching pages for link previews.
|
|
if (payload.method === 'pick_image') {
|
|
return { status: 'ok', data: await pickImage() };
|
|
}
|
|
if (payload.method === 'link_preview') {
|
|
const url = String(params.url ?? '');
|
|
return { status: 'ok', data: url ? await fetchLinkPreview(url) : null };
|
|
}
|
|
if (payload.method === 'upload_image') {
|
|
const token = typeof params.token === 'string' ? params.token : '';
|
|
const file = token ? pickedTokens.get(token) : undefined;
|
|
pickedTokens.delete(token);
|
|
if (!file) {
|
|
return {
|
|
status: 'error',
|
|
code: 'unknown_token',
|
|
message: 'That image selection has expired. Please attach it again.',
|
|
details: null,
|
|
};
|
|
}
|
|
try {
|
|
return { status: 'ok', data: await uploadImage(file) };
|
|
} catch (error) {
|
|
return {
|
|
status: 'error',
|
|
code: 'upload_failed',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
details: null,
|
|
};
|
|
}
|
|
}
|
|
return backendRequest(payload.method, params);
|
|
},
|
|
);
|
|
|
|
ipcMain.handle('app:selfupdate', () => selfUpdate());
|
|
|
|
ipcMain.handle('clipboard:write', (_event, text: string) => {
|
|
clipboard.writeText(String(text));
|
|
return true;
|
|
});
|
|
|
|
createWindow();
|
|
|
|
if (pendingGiveUpDialog) {
|
|
dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog);
|
|
pendingGiveUpDialog = null;
|
|
}
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on('before-quit', () => {
|
|
markStartupCleanExit();
|
|
if (backend) {
|
|
backend.kill();
|
|
}
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
app.quit();
|
|
}
|
|
});
|