Files
feedBack-desktop/src/main/main.ts
T
byrongamatosandClaude Opus 4.8 48cd361dd3 feat(config): real config reset/repair + migration framework (drop manual-delete)
Eliminates the fragile "delete the config folder before upgrading" tester
instruction, which was wrong-by-OS because the userData folder name was
derived inconsistently per platform (fee[dB]ack on macOS, slopsmith-desktop
on Linux/Windows).

A. Deterministic paths + migration framework
- Pin the userData name on every OS via app.setName('feedback-desktop') +
  build.extraMetadata.name; brand (productName 'fee[dB]ack') unchanged.
- One-time userData migration copies a legacy folder into the new one so
  upgraded users don't start fresh (atomic copy-then-rename, fail-soft).
  Runs before the single-instance lock / crashReporter, which would otherwise
  create userData and defeat the "new dir doesn't exist" gate.
- config-migrations.ts: versioned, ordered, idempotent, fail-soft migration
  runner stamped in CONFIG_DIR/config_version.json; logs the active CONFIG_DIR
  at startup (closes the Linux ~/.local/share/slopsmith shared-config gap).

B. In-app "Reset / repair configuration" (Settings panel)
- Granular options: reset app settings & caches, clear plugin state & cached
  Python deps, and full reset with default-OFF opt-ins for installed plugins /
  song library / ML caches.
- config-paths.ts is the single source of truth for per-OS path enumeration;
  the song library, installed plugins and ML caches are structurally confined
  to optInExtras and never wiped by the safe/full categories.
- Reset stops the backend, deletes immediate paths, includes SQLite WAL/SHM
  sidecars + the migration stamp on full reset, and defers Chromium/Crashpad
  state to next launch (consumed before any window reopens it). ML caches honor
  TORCH_HOME/HF_HOME. Empty selection is a no-op (backend left running).
- SECURITY: destructive resets require a native main-process confirmation
  dialog — the renderer bridge is reachable by plugin scripts, so a
  renderer-only confirm is not a sufficient gate.

Tests: node:test suites for path enumeration (per-OS + library/plugins
preserved), migration idempotency/fail-soft, reset delete pipeline guarantees,
userData migration, and deferred-deletion schedule/consume. `npm test` green
(adds a test script). codex review --base origin/main clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:05:18 +02:00

1231 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Slopsmith Desktop — Electron Main Process
// Manages: window lifecycle, Python subprocess, audio engine bridge, plugin management
// ── Velopack startup hook ─────────────────────────────────────────────────
// MUST run before ANY other side-effecting code (crashReporter, app event
// listeners, the rest of the imports below). When Windows invokes
// `Update.exe` with `--veloapp-install`/`--veloapp-updated`/`--veloapp-firstrun`
// it relaunches our exe with those flags; `VelopackApp.build().run()` is what
// detects them, runs the appropriate hook, and exits. If the hook doesn't
// run first the bootstrapper silently breaks install/upgrade flows.
// On macOS the hook just returns (no-op).
// Linux has no Velopack pipeline (electron-builder AppImage/deb only), so the
// native module is never needed there — skip the require entirely so loading
// it on an unsupported platform can never crash startup. On win/mac a load
// failure is also caught: a broken updater is recoverable, a dead app is not.
if (process.platform !== 'linux') {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { VelopackApp } = require('velopack') as typeof import('velopack');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { maybeUninstallLegacyNsis } = require('./nsis-migration') as typeof import('./nsis-migration');
VelopackApp.build()
// Fires during MSI install (the InstallHookDeferred custom
// action in vpk's WiX template, post-InstallFiles). Detects a
// legacy NSIS install at HKLM\...\Uninstall\Slopsmith, runs
// its QuietUninstallString, and restores the Velopack stub
// that NSIS deletes as part of its cleanup. No-op on machines
// without the legacy install. 30-second hard budget — runs
// synchronously with a sync registry-poll, well within it.
.onAfterInstallFastCallback(() => {
try {
maybeUninstallLegacyNsis();
} catch (err) {
console.error('[main] NSIS cleanup hook failed:', err);
}
})
.run();
} catch (err) {
// Never crash over this — a launchable app beats a dead one. But in a
// packaged build a hook failure means install/update lifecycle flags
// won't be handled, so surface it with a dialog instead of a console
// line nobody reads. In dev/unpackaged builds the hook is a harmless
// no-op, so a logged warning is enough there.
console.error('[main] Velopack startup hook failed:', err);
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app, dialog } = require('electron');
if (app.isPackaged) {
dialog.showErrorBox(
'fee[dB]ack update system error',
'The Velopack updater failed to initialize. fee[dB]ack will still '
+ 'run, but automatic updates may not work until it is reinstalled.'
+ `\n\n${String(err)}`,
);
}
}
}
// ──────────────────────────────────────────────────────────────────────────
import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, powerSaveBlocker, systemPreferences } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
import { execFileSync } from 'child_process';
import { migrateUserDataIfNeeded, consumePendingResetIfNeeded } from './config-bootstrap';
// Pin the userData folder name on every OS. Before this it was derived from the
// build name and differed per-OS ('fee[dB]ack' on macOS, 'slopsmith-desktop' on
// Linux/Windows), so a single "delete the config folder" instruction could never
// be right everywhere. setName() must run before ANY app.getPath()/crashReporter/
// whenReady so the deterministic path (<appData>/feedback-desktop) is used
// throughout. It does NOT change the user-facing brand (productName 'fee[dB]ack');
// with the name pinned, the path-hostile brackets never reach the filesystem.
app.setName('feedback-desktop');
// One-time copy of an existing legacy userData folder into the new one, so
// testers don't start fresh after the rename. MUST run before BOTH crashReporter
// (which creates <userData>/Crashpad) AND requestSingleInstanceLock() (which
// writes a SingletonLock into userData) — the migration gate is "new userData
// doesn't exist yet", so anything that creates it first would silently skip the
// migration and start the upgraded user fresh.
migrateUserDataIfNeeded();
// Acquire the single-instance lock now so the primary-only deferred-reset cleanup
// runs ONLY in the instance that will actually boot: a losing second instance
// must not consume the pending-reset manifest while the primary still holds
// Chromium / Crashpad files open. requestSingleInstanceLock() must be called
// exactly once; the lock branch at the bottom of this file reuses this result.
// SLOPSMITH_ALLOW_MULTIPLE=1 opts out (two builds side-by-side).
const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1';
const hasSingleInstanceLock = allowMultipleInstances || app.requestSingleInstanceLock();
if (hasSingleInstanceLock) {
// Apply any reset deletions deferred from a previous "Reset configuration"
// run, before crashReporter / any BrowserWindow reopens Chromium state &
// Crashpad, so those held-open paths can actually be removed.
consumePendingResetIfNeeded();
}
// Enable Electron's Crashpad to capture native crashes (incl. VST/JUCE C++
// access violations) into <userData>/Crashpad/reports/ as .dmp files. Must
// run before app.whenReady(). uploadToServer:false keeps dumps local — they
// can be inspected with WinDbg / minidump-stackwalk.
crashReporter.start({
productName: 'feedback-desktop',
companyName: 'feedback',
submitURL: '',
uploadToServer: false,
compress: false,
});
import { startPython, stopPython, waitForPython, getPythonPort, StartupStatus, restartPython, getLanUrls, getConfigDir } from './python';
import { runConfigMigrations } from './config-migrations';
import { registerMaintenanceHandlers } from './config-reset';
import {
IPC_STARTUP_STATUS,
IPC_STARTUP_GET_STATUS,
IPC_STARTUP_REQUEST_STATUS,
IPC_UPDATE_GET_STATUS,
IPC_UPDATE_SET_CHANNEL,
IPC_UPDATE_CHECK_NOW,
IPC_UPDATE_APPLY,
IPC_POWER_SET_SCREEN_AWAKE,
} from './ipc-channels';
import { initAudioBridge, shutdownAudio } from './audio-bridge';
import { initDebugLogging, isDebugEnabled } from './debug-log';
import { initPluginManager } from './plugin-manager';
import { initSoundfontManager, getDesktopConfig, setDesktopConfig } from './soundfont-manager';
import * as updateManager from './update-manager';
import type { UpdateChannel } from './update-manager';
// Linux: enable Chromium's PipeWire capturer feature so getUserMedia can see
// audio devices on PipeWire-only distros (Fedora 36+, recent Ubuntu, Arch).
// Without this, Chromium falls back to PulseAudio enumeration, which on
// PipeWire systems sometimes returns an empty device list even when the JUCE
// engine sees the hardware fine. Must be set BEFORE app.whenReady() resolves —
// command-line switches are read during Chromium initialization.
//
// This is paired with the per-session permission handler installed in
// startup() below; together they unblock any renderer code that still calls
// navigator.mediaDevices.getUserMedia (the bundled note_detect plugin has
// since been routed through the JUCE bridge in
// slopsmith-plugin-notedetect#27, but third-party plugins may still hit the
// Web-Audio path on their own).
// Whether unprivileged user namespaces are usable — the sandbox path Chromium
// falls back to when the SUID helper is unavailable (as it always is from an
// AppImage). We don't guess purely from kernel knobs: AppArmor restriction
// (Ubuntu 24.04+) and other policy aren't fully reflected by sysctls. Instead we
// actually attempt to create a userns in a short-lived child — exactly what
// Chromium's sandbox does — and fall back to the knobs only when we can't probe.
function linuxUnprivilegedUsernsAvailable(): boolean {
const readsZero = (p: string): boolean => {
try { return fs.readFileSync(p, 'utf8').trim() === '0'; } catch { return false; }
};
// Cheap definite negatives — skip the probe when the kernel says no outright.
if (readsZero('/proc/sys/kernel/unprivileged_userns_clone')) return false;
if (readsZero('/proc/sys/user/max_user_namespaces')) return false;
// Authoritative probe: try to create an unprivileged user namespace in a
// short-lived child via util-linux `unshare`. Success → userns works for our
// children, so Chromium can sandbox. A non-zero exit (EPERM, incl. AppArmor
// denial) → unavailable. We never call unshare in-process, so the main
// process is never moved into a namespace.
try {
execFileSync('unshare', ['--user', 'true'], { timeout: 2000, stdio: 'ignore' });
return true;
} catch (e) {
// `unshare` binary not present → can't probe; fall back to the AppArmor
// knob ('1' = restricted → treat as unavailable), else assume available.
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') {
try {
return fs.readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() !== '1';
} catch { return true; }
}
// unshare ran but the namespace was denied (or timed out) → unavailable.
return false;
}
}
if (process.platform === 'linux') {
// Merge with any existing `--enable-features=` value (set by Electron
// defaults, parent env, or future code) instead of overwriting — a bare
// appendSwitch would replace the comma-separated list and silently
// disable everything else that was enabled. Split-and-dedupe so the
// value stays stable across re-initializations (or if Chromium itself
// already has WebRTCPipeWireCapturer in its baseline list).
const existing = app.commandLine.getSwitchValue('enable-features');
const features = new Set<string>(
(existing || '').split(',').map((f) => f.trim()).filter(Boolean),
);
features.add('WebRTCPipeWireCapturer');
app.commandLine.appendSwitch('enable-features', Array.from(features).join(','));
// chrome-sandbox SUID abort fix (issue #438). From an AppImage the bundled
// chrome-sandbox is inert — the squashfs is mounted nosuid so its SUID bit
// can't take effect — and Chromium falls back to the unprivileged-userns
// sandbox. On kernels where userns is disabled (Debian/Arch hardening) or
// AppArmor-restricted (Ubuntu 24.04+), BOTH sandbox paths fail and Electron
// ABORTS before any app code runs. We can't enable userns or make a nosuid
// mount honor SUID, so on exactly that combination — running as an AppImage
// AND no usable userns — fall back to --no-sandbox so the app launches at
// all. The sandbox stays ON for .deb installs, dev runs, and every
// userns-capable system; this only triggers where Electron would otherwise
// die. Set before app.whenReady() so it's read during Chromium init.
if (process.env.APPIMAGE && !linuxUnprivilegedUsernsAvailable()) {
console.warn(
'[startup] AppImage on a kernel without usable unprivileged user '
+ 'namespaces — launching with --no-sandbox so the app can start '
+ '(the Chromium process sandbox is disabled). To keep the sandbox, '
+ 'install the .deb, enable kernel.unprivileged_userns_clone=1, or '
+ 'run the AppImage on a userns-capable kernel. (issue #438)',
);
app.commandLine.appendSwitch('no-sandbox');
}
}
// Prevent error dialogs from showing when the Python subprocess has issues.
// Both handlers log and swallow — don't let a stray rejection in one of the
// subsystems tear the whole app down.
process.on('uncaughtException', (err) => {
console.error('[main] Uncaught exception:', err.message);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[main] Unhandled rejection at:', promise, 'reason:', reason);
});
let mainWindow: BrowserWindow | null = null;
let splashWindow: BrowserWindow | null = null;
// Set to true when the user initiates a quit so any in-flight startup work
// can bail out instead of racing the window teardown.
let appQuitting = false;
// Set to true the moment WE programmatically dismiss the splash (renderer
// painted, or the safety timeout fired). The splash 'close' handler treats a
// non-terminal close as a user-initiated quit; this flag lets it distinguish
// our intentional dismissal from the user hitting Alt+F4 on the splash.
let splashDismissing = false;
// Backstop timer: if the main window never finishes loading the renderer
// (did-finish-load on the server origin), close the splash and surface an
// error rather than leaving it spinning forever. The backend is already ready
// when we arm this (we're past core-ready), so the renderer only needs to
// fetch http://127.0.0.1:<port>/ and paint — a minute is very generous.
let splashSafetyTimer: ReturnType<typeof setTimeout> | null = null;
const SPLASH_RENDERER_DEADLINE_MS = 60_000;
// Milliseconds to keep the splash visible after a terminal error so the
// renderer has time to paint the final message before the window closes.
const SPLASH_CLOSE_DELAY_MS = 300;
let startupStatusSnapshot: StartupStatus = {
running: true,
phase: 'booting',
message: 'Starting fee[dB]ack...',
currentPlugin: '',
loaded: 0,
total: 0,
error: null,
};
function getResourcesPath(): string {
return app.isPackaged
? path.join(process.resourcesPath)
: path.join(__dirname, '..', '..');
}
function publishStartupStatus(status: Partial<StartupStatus>): void {
startupStatusSnapshot = { ...startupStatusSnapshot, ...status };
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.webContents.send(IPC_STARTUP_STATUS, startupStatusSnapshot);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IPC_STARTUP_STATUS, startupStatusSnapshot);
}
}
// Programmatically dismiss the splash. Idempotent. Sets splashDismissing so
// the 'close' handler below doesn't mistake this for a user-initiated quit,
// and clears the safety timer so it can't fire after we're done.
function dismissSplash(): void {
splashDismissing = true;
if (splashSafetyTimer) {
clearTimeout(splashSafetyTimer);
splashSafetyTimer = null;
}
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close();
}
}
// Terminal failure path: the renderer never painted, so the app is unusable.
// Two callers race here — the did-fail-load give-up branch (load retries
// exhausted) fires within ~30 s, while the splashSafetyTimer covers the case
// where the load silently hangs (server accepts the socket but never responds,
// so neither did-finish-load nor did-fail-load ever fires). Whichever fires
// first wins: dismissSplash() sets splashDismissing, so the same guard that
// makes dismissSplash idempotent short-circuits the second caller here too —
// no double error dialog. Surface the failure, tear the splash down, and quit.
function failRendererStartup(reason: string): void {
if (appQuitting || splashDismissing) return;
publishStartupStatus({ message: reason, phase: 'error', running: false });
if (mainWindow && !mainWindow.isDestroyed()) {
dialog.showErrorBox(
'fee[dB]ack failed to start',
'The app window did not finish loading. Please restart fee[dB]ack. '
+ 'If the problem persists, check the backend logs.',
);
}
dismissSplash();
app.quit();
}
function createSplashWindow(): void {
splashWindow = new BrowserWindow({
width: 560,
height: 360,
alwaysOnTop: true,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
frame: false,
show: true,
title: 'fee[dB]ack',
backgroundColor: '#050508',
webPreferences: {
preload: path.join(__dirname, 'splash-preload.js'),
nodeIntegration: false,
contextIsolation: true,
// sandbox must be false so the preload script can require('electron')
// (ipcRenderer). contextIsolation: true keeps the renderer isolated.
sandbox: false,
},
});
splashWindow.loadFile(path.join(__dirname, 'splash.html'));
// Treat a user-initiated close (Alt+F4 / Cmd+W) before the renderer paints
// as an explicit quit, so they are never stuck staring at the splash.
// preventDefault() keeps the splash visible while app.quit() propagates
// through before-quit → will-quit and tears the rest of startup down.
splashWindow.on('close', (event) => {
const currentPhase = startupStatusSnapshot.phase;
// A user-initiated close (Alt+F4 / Cmd+W) before startup finishes is a
// quit. But once the renderer has painted we dismiss the splash via
// dismissSplash() while the backend is only at core-ready (plugins
// still installing in the background, streamed to the renderer) — that
// intentional dismissal sets splashDismissing and must NOT quit.
if (!appQuitting && !splashDismissing && currentPhase !== 'complete' && currentPhase !== 'error') {
event.preventDefault();
app.quit();
}
});
splashWindow.on('closed', () => {
splashWindow = null;
});
}
// Shared by the main BrowserWindow and any same-origin popup spawned via
// window.open (see setWindowOpenHandler / popupOverrideOptions below).
// Single source of truth so a future security-sensitive change (preload
// path, sandbox, webSecurity, isolation) can't update one path and leave
// the other diverged.
// - sandbox: false is required for the preload to use require('electron').
// - webSecurity: false lets the renderer load mixed-origin assets from
// the localhost Python server.
const rendererWebPreferences: Electron.WebPreferences = {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: false,
};
// Off-origin sub-frame origins the renderer is allowed to embed. The
// will-frame-navigate guards below block every other off-origin sub-frame so
// a remote iframe can't ride the privileged preload, but the tutorials plugin
// legitimately embeds YouTube. This is safe only because preload.ts now gates
// its IPC bridge to the main frame — an allow-listed embed frame loads with no
// slopsmithDesktop surface. Host-suffix match (exact host or `.`-prefixed
// sub-domain) so `evil-youtube.com` / `youtube.com.evil.com` don't slip past.
const EMBED_ALLOWED_HOSTS = ['youtube.com', 'youtube-nocookie.com'];
function isAllowedEmbedUrl(url: string): boolean {
let host: string;
try {
const u = new URL(url);
if (u.protocol !== 'https:') return false;
host = u.hostname.toLowerCase();
} catch {
return false;
}
return EMBED_ALLOWED_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
}
// True for a host that resolves to this machine's loopback. Covers the whole
// 127.0.0.0/8 block, IPv6 loopback (bracketed or bare), IPv4-mapped-IPv6
// loopback, the unspecified address 0.0.0.0/:: (which routes to loopback on the
// local host), and localhost (with optional trailing dot). Used by the egress
// guard so an off-origin sub-frame can't dodge it with an aliased local host.
function isLoopbackHost(host: string): boolean {
const h = host.toLowerCase().replace(/^\[|\]$/g, '');
if (h === 'localhost' || h === 'localhost.') return true;
if (h === '::1' || h === '::' || h === '0.0.0.0') return true;
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
// IPv4-mapped IPv6, dotted tail: ::ffff:127.0.0.1
const dotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) return /^127\./.test(dotted[1]);
// IPv4-mapped IPv6, hex tail (the form `new URL().hostname` normalizes to):
// ::ffff:7f00:1 etc. Decode the embedded 32-bit IPv4 and test 127.0.0.0/8.
const hex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
const v4 = ((parseInt(hex[1], 16) << 16) | parseInt(hex[2], 16)) >>> 0;
return (v4 >>> 24) === 0x7f;
}
return false;
}
// True when `origin` is a concrete remote origin — a real scheme://host that is
// not our renderer. Opaque origins (a fresh about:blank/srcdoc frame reports
// "null") and the renderer origin itself return false, so this flags only a
// frame that has actually loaded a remote page (e.g. the YouTube embed).
function isConfirmedRemoteOrigin(origin: string, isRendererOrigin: (u: string) => boolean): boolean {
if (!origin || origin === 'null') return false;
if (isRendererOrigin(origin)) return false;
try { new URL(origin); return true; } catch { return false; }
}
// True when `url` targets a local service the embed has no business reaching:
// any loopback host, OR our backend's own port on any host (the backend binds
// 0.0.0.0 in LAN mode, so it is also reachable on the machine's LAN IP — the
// port match catches that without us having to enumerate interfaces).
function isLocalServiceUrl(url: string, backendPort: number): boolean {
try {
const u = new URL(url);
return isLoopbackHost(u.hostname) || u.port === String(backendPort);
} catch {
return false;
}
}
function createWindow(port: number): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
title: 'fee[dB]ack',
backgroundColor: '#0f172a', // slate-900 to match Slopsmith UI
webPreferences: rendererWebPreferences,
});
// Forward renderer console to main process stdout
mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
const prefix = ['[renderer:verbose]', '[renderer:info]', '[renderer:warn]', '[renderer:error]'][level] || '[renderer]';
console.log(`${prefix} ${message}`);
});
const serverUrl = `http://127.0.0.1:${port}`;
// Small delay to ensure server is fully accepting connections, then load
setTimeout(() => mainWindow?.loadURL(serverUrl), 500);
// Retry loading if the server wasn't reachable yet. Previously this
// retried just once, which left the window stuck on Chromium's
// built-in error page when the python lifespan startup happened to
// run long (or a zombie was holding the candidate port). Retry up
// to maxRetries × intervalMs (~30 s total) with a fixed cadence —
// long enough to ride out cold-cache plugin imports without giving
// up. Only retry on the network-side error codes that indicate
// "server not up yet"; don't retry on 404/etc. that mean the server
// is up but served something we can't load.
// -102 = ERR_CONNECTION_REFUSED
// -6 = ERR_FILE_NOT_FOUND (rare; shows up on transient races)
// -118 = ERR_CONNECTION_TIMED_OUT
// -2 = ERR_FAILED (catch-all for transient socket errors)
const retryableErrors = new Set([-102, -6, -118, -2]);
const maxRetries = 20;
const retryIntervalMs = 1500;
let retryCount = 0;
mainWindow.webContents.on('did-fail-load', (_event, errorCode) => {
if (!retryableErrors.has(errorCode)) return;
if (retryCount >= maxRetries) {
console.log(`[main] gave up loading ${serverUrl} after ${maxRetries} retries (last errorCode=${errorCode})`);
// Retries are exhausted — the renderer will never paint. Surface the
// failure now rather than letting the splash spin until the safety
// timer's full deadline (the give-up budget is well under it).
failRendererStartup(`The app window failed to load (network error ${errorCode}). Please restart fee[dB]ack.`);
return;
}
retryCount += 1;
setTimeout(() => {
if (!mainWindow) return;
mainWindow.loadURL(serverUrl);
}, retryIntervalMs);
});
// "Is this exactly the renderer origin we loaded?" — reused by the
// did-finish-load paint check and the off-origin navigation guards below.
// Matches scheme+host+port exactly, so a stray load of some *other*
// loopback port can't be mistaken for our renderer.
const isRendererOrigin = makeRendererOriginPredicate(port);
// Inject mutable sync offset after page loads (default 200ms, overridden by settings).
// Gate on the actual renderer origin — Chromium fires did-finish-load on its
// built-in error pages too, and those have a null origin, so reading
// localStorage from them throws SecurityError. Only inject when we
// actually loaded the renderer origin.
mainWindow.webContents.on('did-finish-load', () => {
const url = mainWindow?.webContents.getURL() || '';
// Chromium fires did-finish-load on its built-in error pages too (null
// origin); only treat a load of the real renderer origin as "renderer
// painted". This also gates the splash dismissal below — an error page
// must NOT be taken as a successful paint.
//
// Note we deliberately do NOT distinguish a server-served 4xx/5xx body
// here: those share the real renderer origin, carry no status on this
// event, and aren't a realistic state anyway — waitForPython() already
// confirmed backend health (an /api/plugins probe) before this window
// was created, so a 5xx at / would be a fluke. Only the null-origin
// Chromium error pages (and any stray load of another loopback port)
// are filtered by the exact-origin check below.
if (!isRendererOrigin(url)) return;
mainWindow?.webContents.executeJavaScript(`
window._slopsmithSyncOffset = parseFloat(localStorage.getItem('slopsmith-sync-offset') || '0.2');
`).catch(() => {});
// The renderer has painted, so the app is usable — dismiss the splash
// even though plugins may still be installing in the background. Their
// status streams to the renderer (SSE) and renders as disabled
// "installing…" nav entries; the splash no longer waits on them (#421).
dismissSplash();
});
// Block in-window navigation away from the renderer origin. The window
// ships with `webSecurity: false` so the renderer can load
// mixed-origin assets from the localhost server, but that same loose
// setting means a stray click on a same-window link (or a 30x
// redirect served through the local proxy) would still load a remote
// page in our chrome — same preload, same exposed IPC. The permission
// handler installed in startup() denies media/clipboard/etc for that
// case, but it doesn't stop the navigation itself.
//
// Allow only navigations whose target matches the resolved renderer
// origin. Anything else gets cancelled here and (via the
// setWindowOpenHandler below) re-routed to the user's default browser
// via shell.openExternal. (isRendererOrigin is derived once near the top
// of createWindow above and reused here — the permission handler keeps its
// own copy in a closure that isn't exposed.)
// Block off-origin navigations at every layer Electron exposes:
//
// - `will-navigate`: user/script-initiated navigations on the main
// frame (link clicks, `location =`). Doesn't fire for
// programmatic loadURL — that's how we still let the initial
// load succeed.
// - `will-redirect`: server-side 30x redirects during an
// in-progress navigation. The local proxy returning a 302 to a
// remote URL would otherwise bypass `will-navigate`.
// - `will-frame-navigate`: navigations inside *any* frame
// (including the main frame). With `webSecurity: false` and a
// privileged preload running in every frame, an iframe loading
// a remote URL would inherit the IPC surface. We skip the main
// frame here so we don't double-process what `will-navigate`
// already handled.
//
// Electron 35 fires all three with a single `details` Event whose
// `url` / `isMainFrame` are properties on the event, *not*
// positional callback args. Reading them positionally returns
// undefined and silently inverts the policy — `preventDefault()`
// fires on every navigation including legitimate ones.
function blockOffOriginTopLevel(reason: string) {
return (details: Electron.Event<{ url: string }>) => {
const navUrl = details.url;
if (isRendererOrigin(navUrl)) return;
details.preventDefault();
console.warn(`[main] Blocked ${reason} to non-renderer origin: ${navUrl}`);
// Only forward web URLs to the system browser. `file:`,
// `javascript:`, `mailto:`, or custom schemes would
// otherwise trigger the user's registered protocol handler
// from a page-controlled string — a foot-gun even for a
// navigation we're already blocking.
openWebUrlExternally(navUrl);
};
}
mainWindow.webContents.on('will-navigate', blockOffOriginTopLevel('in-window navigation'));
mainWindow.webContents.on('will-redirect', blockOffOriginTopLevel('cross-origin redirect'));
// A sub-frame that has loaded a remote page (the YouTube embed) must not
// navigate itself — or submit a form — to a local service. Such a document
// request would otherwise reach the backend: will-frame-navigate allows any
// renderer-origin target, and the onBeforeRequest egress guard exempts
// document loads. The onBeforeRequest guard only sees the per-resource
// frame; here we have the frame's pre-navigation origin, which is the only
// place we can tell "the YouTube frame is steering itself at 127.0.0.1" from
// "a same-origin/fresh frame loads an app page". Returns true when blocked.
function blockRemoteFrameToLocalService(details: Electron.Event<{ url: string; isMainFrame: boolean }>): boolean {
const frame = (details as { frame?: Electron.WebFrameMain | null }).frame;
if (!frame || !isLocalServiceUrl(details.url, port)) return false;
// Walk the ancestor chain, not just this frame's origin: a remote embed
// can spawn a FRESH child iframe (opaque origin) and steer THAT at the
// backend. The child isn't a confirmed-remote origin itself, but its
// parent (the embed) is — so any remote frame anywhere above this one
// means a remote page is driving the navigation.
let remoteAncestor: string | null = null;
for (let f: Electron.WebFrameMain | null = frame; f; f = f.parent) {
if (isConfirmedRemoteOrigin(f.origin, isRendererOrigin)) { remoteAncestor = f.origin; break; }
}
if (!remoteAncestor) return false;
details.preventDefault();
console.warn(`[main] Blocked remote frame ${remoteAncestor} from navigating to local service: ${details.url}`);
return true;
}
mainWindow.webContents.on('will-frame-navigate', (details) => {
// Top-level frame is handled by will-navigate above; skip so
// we don't double-log or route to openExternal twice.
if (details.isMainFrame) return;
if (blockRemoteFrameToLocalService(details)) return;
const navUrl = details.url;
if (isRendererOrigin(navUrl)) return;
// Allow trusted media embeds (e.g. the tutorials plugin's YouTube
// player). Safe because the preload bridge is main-frame-only, so the
// embed frame carries no privileged IPC surface.
if (isAllowedEmbedUrl(navUrl)) return;
details.preventDefault();
// Don't openExternal subframe blocks — popping the system
// browser every time an embedded video / ad-frame tries to
// load is worse UX than silently refusing.
console.warn(`[main] Blocked subframe navigation to non-renderer origin: ${navUrl}`);
});
// window.open() routing:
//
// - Same renderer-origin URLs → allow as an Electron BrowserWindow
// that mirrors the main window's webPreferences (same preload,
// same isolation, same webSecurity: false). Plugin pop-outs like
// splitscreen rely on this so the popup shares the renderer's
// BroadcastChannel scope and preload-exposed IPC. Without it,
// `action: 'deny'` returns null to window.open() (which the
// plugin reads as "popup blocked") AND the URL leaks to the
// system browser via openWebUrlExternally, where BroadcastChannel
// can't reach across Chromium instances.
//
// - Off-origin URLs → route to the system browser. Same scheme
// gate as will-navigate above (openWebUrlExternally restricts to
// http/https) since a target=_blank or stray window.open from a
// plugin can supply any string.
const popupOverrideOptions: Electron.BrowserWindowConstructorOptions = {
backgroundColor: '#0f172a',
webPreferences: rendererWebPreferences,
};
const rendererWindowOpenHandler = ({ url }: { url: string }) => {
if (isRendererOrigin(url)) {
return { action: 'allow' as const, overrideBrowserWindowOptions: popupOverrideOptions };
}
openWebUrlExternally(url);
return { action: 'deny' as const };
};
mainWindow.webContents.setWindowOpenHandler(rendererWindowOpenHandler);
// Apply the same off-origin navigation guards + window-open policy
// to any popup the renderer opens. Otherwise a popup could be told
// to navigate off-origin (or spawn another window.open), and the
// preload-IPC surface installed via popupOverrideOptions would
// follow along to the new page.
//
// Wired recursively: the last line re-registers `did-create-window`
// on each popup's own webContents so nested popups (popup A →
// popup B) inherit the same guards. Without that, only popups
// spawned directly from the main window would be protected, and
// any popup that spawned another would leave its child guard-less
// while still carrying the preload-IPC surface.
function wirePopupGuards(wc: Electron.WebContents): void {
wc.on('will-navigate', blockOffOriginTopLevel('popup in-window navigation'));
wc.on('will-redirect', blockOffOriginTopLevel('popup cross-origin redirect'));
wc.on('will-frame-navigate', (details) => {
if (details.isMainFrame) return;
if (blockRemoteFrameToLocalService(details)) return;
const navUrl = details.url;
if (isRendererOrigin(navUrl)) return;
if (isAllowedEmbedUrl(navUrl)) return;
details.preventDefault();
console.warn(`[main] Blocked popup subframe navigation to non-renderer origin: ${navUrl}`);
});
wc.setWindowOpenHandler(rendererWindowOpenHandler);
wc.on('did-create-window', (nestedWin) => wirePopupGuards(nestedWin.webContents));
}
mainWindow.webContents.on('did-create-window', (popupWin) => {
wirePopupGuards(popupWin.webContents);
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Dev tools in development
if (!app.isPackaged) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
}
// Forward a URL to the OS default browser only if it's a web URL.
// `shell.openExternal` will gladly hand any string to the user's
// registered protocol handlers (file:, javascript:, mailto:, custom
// schemes), and the strings we pass through come from page-controlled
// places (will-navigate, window.open). Restrict to http(s) so a
// malformed link, plugin bug, or attacker-shaped string can't reach
// for arbitrary scheme handlers.
function openWebUrlExternally(url: string): void {
let parsed: URL;
try { parsed = new URL(url); } catch {
console.warn(`[main] Refusing to openExternal malformed URL: ${url}`);
return;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
console.warn(`[main] Refusing to openExternal non-web scheme: ${parsed.protocol}`);
return;
}
// Pass the canonicalised href, not the raw input — page-controlled
// strings can carry whitespace / control characters that the URL
// parser strips, and openExternal should see exactly the bytes we
// validated.
shell.openExternal(parsed.href).catch(() => { /* user dismissed / system error */ });
}
// Permissions we always deny, regardless of origin. These are
// high-impact device APIs Slopsmith has no use for — refusing them up
// front shrinks the attack surface even on the trusted renderer
// origin, so a malicious or compromised plugin can't reach for them.
// Add to this list if a real Slopsmith feature ever needs one.
const DENY_PERMISSIONS = new Set([
'serial',
'hid',
'usb',
'bluetooth',
'geolocation',
'idle-detection',
]);
// Predicate factory: did a permission request originate from the exact
// origin we load the renderer from? The window's `loadURL` target is
// `http://127.0.0.1:${port}` (see createWindow), so the trusted origin
// is precisely that protocol + hostname + port triple — no wider
// hostname allow-list. `http://localhost:${port}` could resolve to a
// different listener on a dual-stack host and must NOT inherit the
// grant.
//
// Parses via WHATWG URL so query strings, fragments, and other
// valid-but-uncommon URL shapes don't trip the check. URL.origin
// canonicalises to `scheme://host:port`, which is exactly the equality
// we want — anything else (different port, different scheme, different
// hostname, malformed URL) compares unequal.
function makeRendererOriginPredicate(rendererPort: number): (url: string) => boolean {
const expectedOrigin = `http://127.0.0.1:${rendererPort}`;
return (url: string): boolean => {
if (!url) return false;
try {
return new URL(url).origin === expectedOrigin;
} catch {
return false;
}
};
}
// Origin- AND port-scoped permission policy for the default session.
//
// Why this exists: clicking Detect in the bundled note_detect plugin on
// Linux used to show "Could not access audio input" (#52) because Chromium
// in Electron silently denies `media` for the localhost-served renderer
// when no permission handler is installed. The plugin itself now routes
// pitch detection through the JUCE bridge, but we still want a defensive
// handler so future renderer code / third-party plugins don't hit the
// same wall. createWindow() also installs a `will-navigate` listener that
// cancels any same-window navigation off the renderer origin, so the
// scenarios this handler defends against are mostly belt-and-braces;
// `webSecurity: false` is still on, so a defense-in-depth permission
// policy is worth keeping anyway.
//
// Policy:
// - Block DENY_PERMISSIONS (serial / hid / usb / bluetooth / geolocation /
// idle-detection) for *every* origin, including the renderer. Slopsmith
// has no use for these; pre-denying them keeps a compromised plugin
// from reaching for them.
// - For `media` from the renderer origin, allow audio-only requests
// (Slopsmith uses the microphone for pitch detection) but deny when
// `details.mediaTypes` includes `video` — we have no camera feature,
// so a getUserMedia({video:true}) call must be a plugin bug or worse.
// - For every other permission, grant when the request comes from the
// exact rendererPort we resolved at startup. This matches Electron's
// prior default-allow for the only origin we actually load, so
// unrelated renderer/plugin features (clipboard, notifications,
// fullscreen, midi, …) keep working unchanged.
// - For any other origin (including other ports on 127.0.0.1, redirects
// to external URLs, etc.), deny. Stops a stray redirect from
// inheriting clipboard / notifications / etc. that would have been
// default-allowed without a handler.
function installRendererPermissions(rendererPort: number): void {
const isRendererOrigin = makeRendererOriginPredicate(rendererPort);
const def = session.defaultSession;
def.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (DENY_PERMISSIONS.has(permission)) {
callback(false);
return;
}
if (!isRendererOrigin(details.requestingUrl || '')) {
callback(false);
return;
}
if (permission === 'media') {
// Electron passes mediaTypes (`'audio'` / `'video'`) for the
// request-handler path. Allow only when the request is
// explicitly audio-only — Slopsmith doesn't use the camera,
// so we want both "video requested" AND "mediaTypes
// missing/empty" to deny (the latter would otherwise let an
// older / synthetic request slip through).
const mediaTypes = (details as { mediaTypes?: string[] }).mediaTypes;
const types = new Set(mediaTypes ?? []);
const audioOnly = types.has('audio') && !types.has('video');
callback(audioOnly);
return;
}
callback(true);
});
def.setPermissionCheckHandler((_wc, permission, requestingOrigin, details) => {
if (DENY_PERMISSIONS.has(permission)) return false;
if (!isRendererOrigin(requestingOrigin || '')) return false;
if (permission === 'media') {
// Mirror the request-handler's audio-only policy in the
// synchronous check path so navigator.permissions.query()
// reports the same state we'll actually grant. Electron's
// check-handler `details` exposes `mediaType` (singular),
// unlike the request-handler's `mediaTypes` (plural array).
// Treat `'video'` and `'unknown'` as denied, only explicit
// `'audio'` as granted.
const mediaType = (details as { mediaType?: string }).mediaType;
return mediaType === 'audio';
}
return true;
});
// Network egress guard for the off-origin embeds the will-frame-navigate
// allow-list now permits (the tutorials YouTube player). The window runs
// `webSecurity: false`, so a remote sub-frame could otherwise use plain
// fetch / XHR / WebSocket to reach the local backend. CORS still blocks it
// from *reading* any response (verified), but a state-changing GET /
// simple-POST would still land (blind CSRF), and an Origin-header check
// misses `no-cors` requests (Origin: null). So gate on the initiating frame:
// only the main frame and same-origin sub-frames may reach a local service.
def.webRequest.onBeforeRequest((details, callback) => {
if (!isLocalServiceUrl(details.url, rendererPort)) {
callback({});
return;
}
// Document navigations (the renderer's own load, sub-frame src loads)
// are policed by createWindow()'s navigation guards — never cancel them
// here, or we'd block the app's own page load (which has no frame yet).
if (details.resourceType === 'mainFrame' || details.resourceType === 'subFrame') {
callback({});
return;
}
// Data requests (xhr/fetch/websocket/…). Allow the main frame and any
// same-origin sub-frame (origin, not url, so inherited-origin about:blank
// / srcdoc frames still count). Deny an off-origin sub-frame (the embed)
// and — fail closed — any request whose frame was torn down mid-flight to
// shed attribution. Slopsmith uses no service/shared workers, so no
// legitimate local-service request is frameless.
const frame = details.frame;
if (frame && (frame.parent === null || isRendererOrigin(frame.origin))) {
callback({});
return;
}
console.warn(`[main] Blocked local-service request from ${frame ? `off-origin subframe ${frame.url}` : 'unattributed frame'}${details.url}`);
callback({ cancel: true });
});
}
// macOS microphone (TCC) gate. The JUCE engine captures guitar input through
// a native CoreAudio input device (AudioEngine opens it during audio.init()),
// NOT through the renderer's getUserMedia — so the Chromium media-permission
// flow (installRendererPermissions / setPermissionRequestHandler) never fires
// for it. On macOS a native CoreAudio input open is gated by TCC, and unless
// the *main app process* has explicitly asked for microphone access the open
// is silently denied: no prompt, a dead input gauge, and — because the engine
// couples the input and output device open — no audio output either. That's
// the reported "no sound and never asks for mic permission" bug; users worked
// around it by launching the binary from Terminal, which makes Terminal the
// responsible TCC process and forces the prompt.
//
// Requesting access here, from the bundle that actually carries
// NSMicrophoneUsageDescription (resources entitlements + extendInfo), shows the
// system prompt once and registers Slopsmith itself in System Settings →
// Privacy & Security → Microphone. Must run BEFORE initAudioBridge() so the
// grant is in place before the native engine opens the input device. No-op off
// macOS (Windows/Linux don't gate capture this way), and best-effort: a denial
// or a throw must not block startup — the engine still runs output-only and the
// renderer surfaces the missing-input state through the normal device UI.
async function ensureMicrophoneAccess(): Promise<void> {
if (process.platform !== 'darwin') return;
// Only run in a packaged app. NSMicrophoneUsageDescription is injected by
// electron-builder via extendInfo in package.json and is only present in
// the built .app bundle. Calling askForMediaAccess() without that Info.plist
// key in an unpackaged dev run (npm start / plain Electron.app) terminates
// the process instead of throwing — the try/catch does not catch it.
if (!app.isPackaged) return;
try {
const status = systemPreferences.getMediaAccessStatus('microphone');
if (status === 'granted') return;
if (status === 'denied' || status === 'restricted') {
// Already a hard 'denied'/'restricted' verdict — askForMediaAccess
// resolves false without re-prompting. Surface it so the log
// explains a silent input; the user must re-enable via System
// Settings (or `tccutil reset Microphone`).
console.warn(`[main] Microphone access is '${status}'; the OS will not re-prompt. ` +
'Enable fee[dB]ack under System Settings → Privacy & Security → Microphone.');
return;
}
// status === 'not-determined' → this triggers the one-time OS prompt.
const granted = await systemPreferences.askForMediaAccess('microphone');
console.log(`[main] Microphone access ${granted ? 'granted' : 'denied'} by user.`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.warn(`[main] Microphone access request failed: ${msg}`);
}
}
async function startup(): Promise<void> {
// Debug logging first so everything below is captured. SLOPSMITH_SANDBOX_DEBUG
// gates the addon's VST_TRACE — flip it whenever debug is *requested*, even
// if the log file couldn't be opened, so addon tracing isn't silently lost.
// The addon caches the var on first read, so it must be set before
// initAudioBridge() loads the .node.
const debugLogPath = initDebugLogging();
if (isDebugEnabled()) {
process.env.SLOPSMITH_SANDBOX_DEBUG = '1';
}
if (debugLogPath) {
console.log(`[main] Debug logging enabled → ${debugLogPath}`);
}
console.log('[main] Starting Slopsmith Desktop...');
// Register startup status IPC handlers before creating the splash window
// so the splash preload's immediate startup:requestStatus is handled.
ipcMain.handle(IPC_STARTUP_GET_STATUS, () => startupStatusSnapshot);
ipcMain.on(IPC_STARTUP_REQUEST_STATUS, (event) => {
event.sender.send(IPC_STARTUP_STATUS, startupStatusSnapshot);
});
createSplashWindow();
publishStartupStatus({ message: 'Starting backend service...', phase: 'booting', running: true });
// Run config-schema migrations against the active backend CONFIG_DIR before
// the backend starts. This replaces "delete the config folder before
// upgrading" with targeted, idempotent, fail-soft migrations. Logging the
// resolved CONFIG_DIR here also closes the visibility gap around the silent
// Linux ~/.local/share/slopsmith shared-config override (python.ts getConfigDir).
try {
const activeConfigDir = getConfigDir();
console.log(`[main] Active CONFIG_DIR: ${activeConfigDir}`);
runConfigMigrations(activeConfigDir, app.getVersion(), new Date().toISOString());
} catch (err) {
console.warn('[main] config migrations failed (continuing):', err);
}
// Register the "Reset / repair configuration" IPC handlers (Settings panel).
// Pass the window getter so the destructive-reset confirmation is a native,
// main-process modal (the renderer bridge is reachable by plugin scripts, so
// a renderer-only confirm is not a sufficient gate).
registerMaintenanceHandlers(() => mainWindow);
// Start Python server (Slopsmith backend)
startPython();
// Prompt for macOS microphone access before the engine opens its native
// CoreAudio input device (see ensureMicrophoneAccess). Awaited so the TCC
// grant lands before initAudioBridge() → audio.init() touches the input.
await ensureMicrophoneAccess();
// Initialize audio engine (JUCE native addon).
initAudioBridge();
// Initialize plugin manager IPC handlers
initPluginManager();
// Initialize soundfont manager IPC handlers (Audio Quality preference)
initSoundfontManager(() => mainWindow);
// Wait for Python server to be ready; null means the backend failed to start.
const port = await waitForPython().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error('[main] Backend failed to start:', message);
publishStartupStatus({ message: `Backend failed to start: ${message}`, phase: 'error', running: false });
return null;
});
if (port === null) {
await new Promise((resolve) => setTimeout(resolve, SPLASH_CLOSE_DELAY_MS));
if (splashWindow && !splashWindow.isDestroyed()) splashWindow.close();
app.quit();
return;
}
console.log(`[main] Python server ready on port ${port}`);
publishStartupStatus({ message: 'Backend ready. Opening app window...', phase: 'core-ready', running: true });
// Permission handlers must be installed before the renderer loads so
// its first permission request hits our policy, not Chromium's
// default. We deferred until now because the policy is scoped to the
// exact renderer port — a stray navigation to another local service
// on a different port must not inherit the trusted-renderer grant.
installRendererPermissions(port);
// Create the main window
createWindow(port);
// Register file picker IPC
ipcMain.handle('dialog:pickFile', async (_event, filters?: { name: string; extensions: string[] }[]) => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
});
return result.canceled ? null : result.filePaths[0];
});
ipcMain.handle('dialog:pickDirectory', async () => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
});
return result.canceled ? null : result.filePaths[0];
});
ipcMain.handle('dialog:pickFiles', async (_event, filters?: { name: string; extensions: string[] }[]) => {
if (!mainWindow) return [];
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
});
return result.canceled ? [] : result.filePaths;
});
// App info
ipcMain.handle('app:getInfo', () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
platform: process.platform,
resourcesPath: getResourcesPath(),
}));
// Config directory
ipcMain.handle('app:getConfigDir', () => {
return app.getPath('userData');
});
// LAN access — opt-in toggle to bind the backend to 0.0.0.0 so other
// devices on the network can reach the library / sync room (issue #441).
// Default is loopback-only; enabling restarts the Python backend so the
// new bind address takes effect on the same port.
ipcMain.handle('network:getLanAccess', () => ({
enabled: !!getDesktopConfig().lanAccess,
urls: getLanUrls(),
}));
ipcMain.handle('network:setLanAccess', async (_event, enabled: unknown) => {
const on = enabled === true;
setDesktopConfig({ lanAccess: on });
// Re-spawn uvicorn with the new --host. Same port is reused (the old
// process releases it first), so the already-loaded 127.0.0.1
// renderer keeps working once the backend is back up.
restartPython();
// Wait for the backend to actually rebind before resolving, so the UI
// doesn't hand out a LAN URL that 404s during the ~restart window. If
// it doesn't come back in time we still report the intended state —
// the renderer surfaces failures through the normal startup path.
try {
await waitForPython();
} catch {
/* backend slow/failed to restart — return intended state anyway */
}
return { success: true, enabled: on, urls: getLanUrls() };
});
// Auto-update (Velopack). The renderer Settings panel reads the persisted
// channel from localStorage and calls setChannel() on boot — we default
// to 'stable' here so the first check runs against the safest feed even
// if the renderer hasn't paged in yet. On Linux every call short-circuits
// to { status: "unsupported", platform: "linux" } inside update-manager.
ipcMain.handle(IPC_UPDATE_GET_STATUS, () => updateManager.getStatus());
ipcMain.handle(IPC_UPDATE_SET_CHANNEL, (_event, channel: unknown) => {
// IPC is untyped at runtime — validate the channel string before forwarding
// so a renderer bug or compromised page can't pass arbitrary values into
// the Velopack SDK.
const VALID_CHANNELS: readonly string[] = ['stable', 'rc', 'beta', 'alpha'];
if (typeof channel !== 'string' || !VALID_CHANNELS.includes(channel)) {
return updateManager.getStatus();
}
updateManager.setChannel(channel as UpdateChannel);
return updateManager.getStatus();
});
ipcMain.handle(IPC_UPDATE_CHECK_NOW, () => updateManager.checkNow());
ipcMain.handle(IPC_UPDATE_APPLY, () => updateManager.applyAndRestart());
// Keep the display awake while a song plays (got-feedback/feedback#686). The
// renderer toggles this via window.slopsmithDesktop.power.setScreenAwake on
// play/pause; the single OS blocker is refcounted across renderers below.
ipcMain.handle(IPC_POWER_SET_SCREEN_AWAKE, (event, keep: unknown) => {
setRendererScreenAwake(event.sender, keep === true);
});
// Boot the updater after the main window exists so the first
// update:available / update:downloaded broadcast has a renderer to land
// in. Renderer will call setChannel() once it reads localStorage.
updateManager.init('stable');
// The splash now dismisses on the main window's did-finish-load (renderer
// painted) — see createWindow(). We no longer poll /api/startup-status to
// gate the splash on full plugin load: plugin status streams straight to
// the renderer over SSE and renders incrementally (#421). Arm a backstop
// so a renderer that never paints still tears the splash down and surfaces
// an error instead of spinning forever. (Exhausted load retries surface
// sooner, from the did-fail-load give-up branch in createWindow().)
splashSafetyTimer = setTimeout(() => {
splashSafetyTimer = null;
// Backstop for a silently hung load (no did-finish-load AND no
// did-fail-load — e.g. the socket is accepted but the response never
// arrives). The retry-exhaustion case surfaces earlier via the
// did-fail-load give-up branch; failRendererStartup() is idempotent so
// whichever path fires first wins.
failRendererStartup('The app window failed to load. Please restart fee[dB]ack.');
}, SPLASH_RENDERER_DEADLINE_MS);
}
// ── Single-instance lock ──────────────────────────────────────────────────
// Without this, each launch spins up its own Python backend (fighting for the
// same fixed port) and its own GPU context; stacking instances starves the GPU
// and was observed to stutter the 3D highway. Hold a process-wide lock: if
// another instance already owns it, surface that window (via 'second-instance'
// on the primary) and quit THIS one before startup() boots a competing backend
// or window. `SLOPSMITH_ALLOW_MULTIPLE=1` opts out so two builds can run
// side-by-side (e.g. A/B testing different versions). The lock was already
// acquired at the top of this file (hasSingleInstanceLock) so primary-only reset
// side effects could run before crashReporter — reuse that result here rather
// than calling requestSingleInstanceLock() a second time.
if (!hasSingleInstanceLock) {
app.quit();
} else {
if (!allowMultipleInstances) {
app.on('second-instance', () => {
// A second launch was attempted — focus the existing window
// instead of opening another. mainWindow may not exist yet if
// we're still on the splash, so fall back to it.
const win = mainWindow ?? splashWindow;
if (win && !win.isDestroyed()) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
});
}
app.whenReady().then(startup);
}
app.on('window-all-closed', () => {
shutdown();
app.quit();
});
app.on('before-quit', () => {
appQuitting = true;
shutdown();
});
// Screen wake lock (got-feedback/feedback#686). The single OS powerSaveBlocker is
// held while at least one renderer wants the screen awake (a song is playing in
// it). Renderers are refcounted in a set so a multi-window setup (the main
// window plus a same-origin popout) stays correct — one window pausing must not
// drop the blocker while another is still playing.
let powerBlockerId: number | null = null;
const powerAwakeRenderers = new Set<Electron.WebContents>();
const powerCleanupWired = new WeakSet<Electron.WebContents>();
function syncPowerBlocker(): void {
if (powerAwakeRenderers.size > 0) {
if (powerBlockerId === null || !powerSaveBlocker.isStarted(powerBlockerId)) {
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep');
}
} else if (powerBlockerId !== null) {
if (powerSaveBlocker.isStarted(powerBlockerId)) powerSaveBlocker.stop(powerBlockerId);
powerBlockerId = null;
}
}
function setRendererScreenAwake(wc: Electron.WebContents, keep: boolean): void {
if (keep) {
powerAwakeRenderers.add(wc);
// Drop this renderer's hold if it reloads, crashes, or its window closes
// before sending setScreenAwake(false), so the blocker can't outlive its
// playback. Listeners are persistent (.on) so repeated reloads/crashes on
// the same WebContents keep cleaning up; wired once per WebContents so
// play/pause cycles don't pile up duplicates.
if (!powerCleanupWired.has(wc)) {
powerCleanupWired.add(wc);
const drop = () => { powerAwakeRenderers.delete(wc); syncPowerBlocker(); };
wc.on('did-start-loading', drop); // reload / navigation
wc.on('render-process-gone', drop); // renderer crash (may recur)
wc.on('destroyed', drop); // window closed
}
} else {
powerAwakeRenderers.delete(wc);
}
syncPowerBlocker();
}
function shutdown(): void {
try {
console.log('[main] Shutting down...');
} catch { /* console may already be gone mid-teardown */ }
powerAwakeRenderers.clear();
syncPowerBlocker();
updateManager.shutdown();
shutdownAudio();
// shutdown() only runs on app quit (window-all-closed / before-quit), and
// the main process exits the moment this synchronous chain returns — so
// stop the backend SYNCHRONOUSLY (SIGKILL the group) rather than relying on
// stopPython()'s async force-kill timer, which would never fire and would
// leave a slow-to-exit uvicorn (graceful shutdown blocking on an in-flight
// scan / conversion / demucs job) orphaned, holding its ML-model RAM.
stopPython(/* immediate */ true);
}
// macOS: re-create window when dock icon is clicked
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
const port = getPythonPort();
if (port > 0) createWindow(port);
}
});