From 3d9e8481ea6c91822799092c74d7ae16d736f432 Mon Sep 17 00:00:00 2001 From: topkoa Date: Sun, 12 Jul 2026 21:05:11 -0400 Subject: [PATCH] fix(panes): break the tray/host import cycle; debounce saves; restore before show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from CodeRabbit on #103. 1. CIRCULAR IMPORT. pane-tray imported pane-hosts while pane-hosts imported pane-tray. In the main process that is not a style question: whichever module loses the load race sees the other's exports half-initialised, and it fails at whatever moment the graph happens to resolve in — which is to say, not on your machine. One direction only now: pane-hosts → pane-tray. What the tray needs from the host (toggle/showAll/hideAll/hasWindow/getMainWindow) is INJECTED through initTray(), wired in main.ts. 2. SYNCHRONOUS DISK WRITES ON EVERY DRAG FRAME. `save()` was wired straight to 'moved'/'resized', and setDesktopConfig is writeFileSync + renameSync. On macOS that is dozens of blocking writes per second, in the main process, while the user drags. Debounced to 400ms — and then flushed on 'close', because a debounce that drops the last move is worse than no debounce: nudge a pane, close it a moment later, and you would lose the position you just chose, which is the exact thing remembered geometry exists to prevent. 'close' (not 'closed') because the window has to still exist to be measured. 3. A MINIMIZED PANE COULD NOT BE BROUGHT BACK. Panes go to the tray by being minimized and then hidden — and hiding a minimized window does not un-minimize it. So show() from the tray restored a window that was still minimized: present, but not on screen. Which reads as the tray being broken. Everything now goes through reveal(), which restores first. 4. PROTOTYPE POLLUTION VIA PANE ID. A pane id arrives from the RENDERER (it is the tail of the frame name window.open() supplied) and is used as a KEY in the persisted paneWindows map. `__proto__` is not an id, it is a way to mutate Object.prototype from a plugin. Rejected on write, the map is rebuilt on a null-prototype object, and reads are own-property checks — otherwise a polluted or hand-edited config hands back geometry for a pane that was never saved. 5. Removed getMainWindowRef(), exported and referenced nowhere. Not applicable: the finding about req.width/req.height/req.title reaching BrowserWindow unvalidated. That was the `pane:open` IPC, which no longer exists — main does not create pane windows at all now (the renderer must, so it can adopt its element into them). The sizes now come from the window Electron already made. Signed-off-by: topkoa --- src/main/main.ts | 2850 ++++++++++++++++++++-------------------- src/main/pane-hosts.ts | 452 ++++--- src/main/pane-tray.ts | 255 ++-- 3 files changed, 1821 insertions(+), 1736 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 7cae489..72f1291 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,1419 +1,1431 @@ -// 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, desktopCapturer, screen } 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 (/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 /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 /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'; -import { installAppMenu } from './app-menu'; -import { sanitizeWindowBounds, MIN_WIDTH, MIN_HEIGHT } from './window-bounds'; -import { initPaneHosts, closeAllPanes, adoptPaneWindow, paneIdFromFrameName } from './pane-hosts'; -import { initTray, destroyTray } from './pane-tray'; - -// 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( - (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'); - } -} - -if (process.platform === 'win32') { - // Pin the WebGL/GPU process to the discrete (high-performance) adapter on - // hybrid-GPU machines. On Windows laptops with an Intel iGPU + NVIDIA/AMD - // dGPU, Chromium's GPU-process adapter selection is non-deterministic across - // launches — one run binds the iGPU, the next the dGPU. When it lands on the - // iGPU the 3D Highway's per-frame WebGL cost blows the draw budget and the - // load-adaptive resolution scaler (feedBack#654, static/highway.js - // _adaptRenderScale) silently drops the canvas to as low as quarter-res to - // hold the frame rate — so the highway renders pixelated even with the - // Quality selector pinned at HD, and which way it goes varies launch to - // launch. The renderer's `powerPreference: 'high-performance'` WebGL hint - // (plugins/highway_3d) is only advisory and does not reliably override the - // OS/Chromium adapter choice. This switch forces the high-performance GPU at - // the Chromium level so the fast path is consistent and the scaler rarely - // engages. Set before app.whenReady() so it's read during Chromium init. - // Desktop machines with a single GPU are unaffected (there's nothing to - // pick); on dual-GPU desktops it likewise selects the discrete card. - app.commandLine.appendSwitch('force_high_performance_gpu'); -} - -// 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:/ and paint — a minute is very generous. -let splashSafetyTimer: ReturnType | 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): 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 -// feedBackDesktop 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 { - // Restore the previous session's window geometry, validated against the - // current display layout so stale bounds (unplugged monitor, resolution - // change, hand-edited config) can't put the window off-screen. When x/y - // are absent Electron centers the window as before. - const restored = sanitizeWindowBounds( - getDesktopConfig().windowBounds, - screen.getAllDisplays().map((d) => d.workArea), - ); - mainWindow = new BrowserWindow({ - x: restored.x, - y: restored.y, - width: restored.width, - height: restored.height, - minWidth: MIN_WIDTH, - minHeight: MIN_HEIGHT, - title: 'fee[dB]ack', - backgroundColor: '#0f172a', // slate-900 to match Slopsmith UI - webPreferences: rendererWebPreferences, - }); - if (restored.maximized) mainWindow.maximize(); - - // Persist geometry on close. getNormalBounds() so a maximized session - // saves the underlying windowed size, restored + re-maximized next launch. - // ponytail: fullscreen is deliberately not persisted (launching straight - // into fullscreen is jarring, especially on macOS) and we save on close - // only — a crash loses the last session's geometry; add debounced - // resize/move saving if that ever matters. - mainWindow.on('close', () => { - if (!mainWindow || mainWindow.isDestroyed()) return; - try { - setDesktopConfig({ - windowBounds: { ...mainWindow.getNormalBounds(), maximized: mainWindow.isMaximized() }, - }); - } catch (err) { - console.warn('[main] Failed to persist window bounds on close:', err); - } - }); - - // 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}`); - }); - - // Whole-app audio capture for exclusive-style outputs (ASIO / WASAPI - // exclusive). The renderer-bus feeder in the static bundle calls - // getDisplayMedia({audio, video}) to capture EVERY sound the app makes - // (song, previews, UI) and push it into the engine's renderer bus — - // per-surface taps can't cover plugin-private AudioContexts. Answer the - // request with this window's own frame as the audio source (frame-scoped: - // other applications' audio is NOT captured — a system 'loopback' would - // leak Discord/etc. into the performance mix) and any screen as the - // required-but-unused video track (the feeder stops it immediately). - // [asio-diag] every outcome below is logged: a renderer-side - // NotAllowedError with NO handler line in the main log means the running - // main predates this handler (stale-main/new-bundle mix). - session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { - console.log('[asio-diag] display-media request received (loopback capture)'); - desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { - if (!mainWindow || sources.length === 0) { - console.warn(`[asio-diag] display-media DENIED: mainWindow=${!!mainWindow} screenSources=${sources.length}`); - callback({}); - return; - } - console.log('[asio-diag] display-media granted: frame audio + screen video'); - callback({ video: sources[0], audio: mainWindow.webContents.mainFrame }); - }).catch((e) => { - console.warn(`[asio-diag] display-media DENIED: getSources failed: ${e?.message ?? e}`); - callback({}); - }); - }); - - // Local-mute companion for the capture above: when the feeder engages - // loopback it must stop the page's audio from ALSO reaching the default - // WASAPI device. Preferred path is the suppressLocalAudioPlayback track - // constraint; this IPC is the fallback when the constraint is - // unsupported. Chromium's capture pipeline taps frame audio before the - // output mute, so a muted page still feeds the captured stream. - ipcMain.handle('audio:setPageMuted', (_event, muted: unknown) => { - if (!mainWindow) return false; - mainWindow.webContents.setAudioMuted(muted === true); - return mainWindow.webContents.isAudioMuted(); - }); - - const serverUrl = `http://127.0.0.1:${port}`; - - // Clear the Chromium HTTP cache before the first load. The server - // historically sent no Cache-Control on /static, so heuristic freshness - // let a NEW build's window run the PREVIOUS build's app.js from disk - // cache (2026-07-11 ASIO investigation: the whole exclusive-reroute chain - // silently missing). The server now sends no-cache, but testers hop - // between portable builds sharing one userData dir — one cheap clear per - // launch makes stale-bundle states impossible regardless of what an - // older build's server cached. - const clearCachePromise = mainWindow.webContents.session.clearCache() - .catch((e) => console.warn(`[main] clearCache failed (continuing): ${e.message}`)); - - // Small delay to ensure server is fully accepting connections, then load - setTimeout(() => { void clearCachePromise.then(() => 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, details) => { - wirePopupGuards(popupWin.webContents); - // A pane pop-out. The RENDERER opened it (window.open) because it moves a - // live DOM node into it and needs a handle on the new document to do that — - // see pane-hosts.ts. We recognise it by the frame name it was opened with - // and give it the OS behaviour a pane should have: remembered bounds, off - // the taskbar, minimize-to-tray, listed in the tray menu. - const paneId = paneIdFromFrameName(details.frameName || ''); - if (paneId) adoptPaneWindow(popupWin, paneId); - }); - - mainWindow.on('closed', () => { - mainWindow = null; - // Pane windows cannot outlive the window that feeds them: without the - // renderer there is nothing on the other end of their BroadcastChannel, - // so they would sit there showing a frozen playhead forever. Worse, a - // pane HIDDEN in the tray is still an open window — leaving one behind - // would stop `window-all-closed` from ever firing and the app would - // linger as an invisible process. - closeAllPanes(); - }); - - // 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) => { - // [asio-diag] getDisplayMedia (loopback capture) rides this path - // ('display-capture' and/or 'media' with video) BEFORE the - // display-media handler — log every deny so an upstream permission - // denial is distinguishable from a handler-level one. - const deny = (why: string) => { - console.warn(`[asio-diag] permission-request DENIED: ${permission} (${why}) url=${details.requestingUrl ?? ''}`); - callback(false); - }; - if (DENY_PERMISSIONS.has(permission)) { - deny('deny-list'); - return; - } - if (!isRendererOrigin(details.requestingUrl || '')) { - deny('non-renderer origin'); - return; - } - if (permission === 'media') { - // Electron passes mediaTypes (`'audio'` / `'video'`) for - // getUserMedia requests. Allow explicit audio-only (microphone - // for pitch detection) and deny camera. getDisplayMedia — the - // renderer-bus whole-app loopback capture — ALSO rides this - // permission but with EMPTY mediaTypes, so empty must be - // allowed or the display-media handler below never runs and - // exclusive/ASIO output loses all page audio (song previews, - // fallback element songs). Verified 2026-07-12: the old - // "empty ⇒ deny" rule was exactly the tester's silent-preview - // bug. Camera exposure stays impossible: getDisplayMedia video - // is the app's own frame, and real camera requests always - // carry mediaTypes=['video']. - const mediaTypes = (details as { mediaTypes?: string[] }).mediaTypes; - const types = new Set(mediaTypes ?? []); - if (types.has('video')) { deny(`media types=[${[...types].join(',')}]`); return; } - callback(true); - return; - } - callback(true); - }); - def.setPermissionCheckHandler((_wc, permission, requestingOrigin, details) => { - if (DENY_PERMISSIONS.has(permission)) { - console.warn(`[asio-diag] permission-check DENIED: ${permission} (deny-list) origin=${requestingOrigin ?? ''}`); - return false; - } - if (!isRendererOrigin(requestingOrigin || '')) { - console.warn(`[asio-diag] permission-check DENIED: ${permission} (non-renderer origin) origin=${requestingOrigin ?? ''}`); - return false; - } - if (permission === 'media') { - // Mirror the request-handler in the synchronous check path so - // navigator.permissions.query() reports the same state we'll - // actually grant: deny camera ('video'), allow audio and the - // type-less display-capture path. Electron's check-handler - // `details` exposes `mediaType` (singular), unlike the - // request-handler's `mediaTypes` (plural array). - const mediaType = (details as { mediaType?: string }).mediaType; - if (mediaType === 'video') { - console.warn(`[asio-diag] permission-check DENIED: media (mediaType=video) origin=${requestingOrigin ?? ''}`); - return false; - } - return true; - } - 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 { - if (process.platform !== 'darwin') { - console.log('[main] ensureMicrophoneAccess: platform !== darwin, skipping'); - 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) { - console.log('[main] ensureMicrophoneAccess: app.isPackaged === false, skipping (NSMicrophoneUsageDescription may be absent)'); - return; - } - try { - const status = systemPreferences.getMediaAccessStatus('microphone'); - console.log(`[main] ensureMicrophoneAccess: getMediaAccessStatus returned '${status}'`); - - if (status === 'granted') { - // TCC database has a grant for this bundle ID, BUT macOS enforces - // the grant against the code signature — a stale entry (keyed to a - // different signature from an earlier build) reports 'granted' here - // while CoreAudio still delivers zeroed input. Log bundle and - // packaging details so the debug log can diagnose signature-mismatch - // vs. genuine grant. - const name = app.getName(); - const ver = app.getVersion(); - const pkg = app.isPackaged; - const resPath = app.getPath('exe'); - console.log(`[main] Microphone access: 'granted' (early return — app="${name}" v${ver} packaged=${pkg} exe="${resPath}")`); - console.log('[main] ensureMicrophoneAccess: returning early — status is granted, no askForMediaAccess call'); - 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. - console.log('[main] Microphone access: status is not-determined, calling askForMediaAccess (system prompt should appear)'); - const granted = await systemPreferences.askForMediaAccess('microphone'); - console.log(`[main] Microphone access ${granted ? 'granted' : 'denied'} by user.`); - - if (!granted) { - // User denied the prompt — log extra context so we can distinguish - // "user clicked Don't Allow" from "prompt never appeared / resolved false". - console.warn('[main] Microphone access: user denied the permission prompt. Input will be silent.'); - } - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - console.warn(`[main] Microphone access request failed: ${msg}`); - // Log the full error stack if available — the try/catch above may not - // catch a process termination (missing NSMicrophoneUsageDescription), - // but anything that does reach here should be diagnosable. - if (e instanceof Error && e.stack) { - console.warn(`[main] Microphone access error stack: ${e.stack}`); - } - } -} - -async function startup(): Promise { - // 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); - - // Detachable panes: the tray that lists them, and the OS behaviour applied to - // each pane window as the renderer opens it (see did-create-window above). - // Must come after createWindow — both reach the renderer through mainWindow, - // and Tray requires a ready app. - initPaneHosts({ getMainWindow: () => mainWindow }); - initTray({ getMainWindow: () => mainWindow }); - - // Install our application menu (replaces Electron's default so View → - // Zoom In also accepts the unshifted Ctrl+= key — see app-menu.ts). - installAppMenu(); - - // 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', 'nightly']; - 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.feedBackDesktop.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(); -const powerCleanupWired = new WeakSet(); - -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 */ } - destroyTray(); - 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); - } -}); +// 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, desktopCapturer, screen } 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 (/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 /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 /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'; +import { installAppMenu } from './app-menu'; +import { sanitizeWindowBounds, MIN_WIDTH, MIN_HEIGHT } from './window-bounds'; +import { + initPaneHosts, closeAllPanes, adoptPaneWindow, paneIdFromFrameName, + togglePaneWindow, showAllPaneWindows, hideAllPaneWindows, hasPaneWindow, +} from './pane-hosts'; +import { initTray, destroyTray } from './pane-tray'; + +// 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( + (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'); + } +} + +if (process.platform === 'win32') { + // Pin the WebGL/GPU process to the discrete (high-performance) adapter on + // hybrid-GPU machines. On Windows laptops with an Intel iGPU + NVIDIA/AMD + // dGPU, Chromium's GPU-process adapter selection is non-deterministic across + // launches — one run binds the iGPU, the next the dGPU. When it lands on the + // iGPU the 3D Highway's per-frame WebGL cost blows the draw budget and the + // load-adaptive resolution scaler (feedBack#654, static/highway.js + // _adaptRenderScale) silently drops the canvas to as low as quarter-res to + // hold the frame rate — so the highway renders pixelated even with the + // Quality selector pinned at HD, and which way it goes varies launch to + // launch. The renderer's `powerPreference: 'high-performance'` WebGL hint + // (plugins/highway_3d) is only advisory and does not reliably override the + // OS/Chromium adapter choice. This switch forces the high-performance GPU at + // the Chromium level so the fast path is consistent and the scaler rarely + // engages. Set before app.whenReady() so it's read during Chromium init. + // Desktop machines with a single GPU are unaffected (there's nothing to + // pick); on dual-GPU desktops it likewise selects the discrete card. + app.commandLine.appendSwitch('force_high_performance_gpu'); +} + +// 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:/ and paint — a minute is very generous. +let splashSafetyTimer: ReturnType | 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): 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 +// feedBackDesktop 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 { + // Restore the previous session's window geometry, validated against the + // current display layout so stale bounds (unplugged monitor, resolution + // change, hand-edited config) can't put the window off-screen. When x/y + // are absent Electron centers the window as before. + const restored = sanitizeWindowBounds( + getDesktopConfig().windowBounds, + screen.getAllDisplays().map((d) => d.workArea), + ); + mainWindow = new BrowserWindow({ + x: restored.x, + y: restored.y, + width: restored.width, + height: restored.height, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + title: 'fee[dB]ack', + backgroundColor: '#0f172a', // slate-900 to match Slopsmith UI + webPreferences: rendererWebPreferences, + }); + if (restored.maximized) mainWindow.maximize(); + + // Persist geometry on close. getNormalBounds() so a maximized session + // saves the underlying windowed size, restored + re-maximized next launch. + // ponytail: fullscreen is deliberately not persisted (launching straight + // into fullscreen is jarring, especially on macOS) and we save on close + // only — a crash loses the last session's geometry; add debounced + // resize/move saving if that ever matters. + mainWindow.on('close', () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + try { + setDesktopConfig({ + windowBounds: { ...mainWindow.getNormalBounds(), maximized: mainWindow.isMaximized() }, + }); + } catch (err) { + console.warn('[main] Failed to persist window bounds on close:', err); + } + }); + + // 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}`); + }); + + // Whole-app audio capture for exclusive-style outputs (ASIO / WASAPI + // exclusive). The renderer-bus feeder in the static bundle calls + // getDisplayMedia({audio, video}) to capture EVERY sound the app makes + // (song, previews, UI) and push it into the engine's renderer bus — + // per-surface taps can't cover plugin-private AudioContexts. Answer the + // request with this window's own frame as the audio source (frame-scoped: + // other applications' audio is NOT captured — a system 'loopback' would + // leak Discord/etc. into the performance mix) and any screen as the + // required-but-unused video track (the feeder stops it immediately). + // [asio-diag] every outcome below is logged: a renderer-side + // NotAllowedError with NO handler line in the main log means the running + // main predates this handler (stale-main/new-bundle mix). + session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { + console.log('[asio-diag] display-media request received (loopback capture)'); + desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { + if (!mainWindow || sources.length === 0) { + console.warn(`[asio-diag] display-media DENIED: mainWindow=${!!mainWindow} screenSources=${sources.length}`); + callback({}); + return; + } + console.log('[asio-diag] display-media granted: frame audio + screen video'); + callback({ video: sources[0], audio: mainWindow.webContents.mainFrame }); + }).catch((e) => { + console.warn(`[asio-diag] display-media DENIED: getSources failed: ${e?.message ?? e}`); + callback({}); + }); + }); + + // Local-mute companion for the capture above: when the feeder engages + // loopback it must stop the page's audio from ALSO reaching the default + // WASAPI device. Preferred path is the suppressLocalAudioPlayback track + // constraint; this IPC is the fallback when the constraint is + // unsupported. Chromium's capture pipeline taps frame audio before the + // output mute, so a muted page still feeds the captured stream. + ipcMain.handle('audio:setPageMuted', (_event, muted: unknown) => { + if (!mainWindow) return false; + mainWindow.webContents.setAudioMuted(muted === true); + return mainWindow.webContents.isAudioMuted(); + }); + + const serverUrl = `http://127.0.0.1:${port}`; + + // Clear the Chromium HTTP cache before the first load. The server + // historically sent no Cache-Control on /static, so heuristic freshness + // let a NEW build's window run the PREVIOUS build's app.js from disk + // cache (2026-07-11 ASIO investigation: the whole exclusive-reroute chain + // silently missing). The server now sends no-cache, but testers hop + // between portable builds sharing one userData dir — one cheap clear per + // launch makes stale-bundle states impossible regardless of what an + // older build's server cached. + const clearCachePromise = mainWindow.webContents.session.clearCache() + .catch((e) => console.warn(`[main] clearCache failed (continuing): ${e.message}`)); + + // Small delay to ensure server is fully accepting connections, then load + setTimeout(() => { void clearCachePromise.then(() => 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, details) => { + wirePopupGuards(popupWin.webContents); + // A pane pop-out. The RENDERER opened it (window.open) because it moves a + // live DOM node into it and needs a handle on the new document to do that — + // see pane-hosts.ts. We recognise it by the frame name it was opened with + // and give it the OS behaviour a pane should have: remembered bounds, off + // the taskbar, minimize-to-tray, listed in the tray menu. + const paneId = paneIdFromFrameName(details.frameName || ''); + if (paneId) adoptPaneWindow(popupWin, paneId); + }); + + mainWindow.on('closed', () => { + mainWindow = null; + // Pane windows cannot outlive the window that feeds them: without the + // renderer there is nothing on the other end of their BroadcastChannel, + // so they would sit there showing a frozen playhead forever. Worse, a + // pane HIDDEN in the tray is still an open window — leaving one behind + // would stop `window-all-closed` from ever firing and the app would + // linger as an invisible process. + closeAllPanes(); + }); + + // 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) => { + // [asio-diag] getDisplayMedia (loopback capture) rides this path + // ('display-capture' and/or 'media' with video) BEFORE the + // display-media handler — log every deny so an upstream permission + // denial is distinguishable from a handler-level one. + const deny = (why: string) => { + console.warn(`[asio-diag] permission-request DENIED: ${permission} (${why}) url=${details.requestingUrl ?? ''}`); + callback(false); + }; + if (DENY_PERMISSIONS.has(permission)) { + deny('deny-list'); + return; + } + if (!isRendererOrigin(details.requestingUrl || '')) { + deny('non-renderer origin'); + return; + } + if (permission === 'media') { + // Electron passes mediaTypes (`'audio'` / `'video'`) for + // getUserMedia requests. Allow explicit audio-only (microphone + // for pitch detection) and deny camera. getDisplayMedia — the + // renderer-bus whole-app loopback capture — ALSO rides this + // permission but with EMPTY mediaTypes, so empty must be + // allowed or the display-media handler below never runs and + // exclusive/ASIO output loses all page audio (song previews, + // fallback element songs). Verified 2026-07-12: the old + // "empty ⇒ deny" rule was exactly the tester's silent-preview + // bug. Camera exposure stays impossible: getDisplayMedia video + // is the app's own frame, and real camera requests always + // carry mediaTypes=['video']. + const mediaTypes = (details as { mediaTypes?: string[] }).mediaTypes; + const types = new Set(mediaTypes ?? []); + if (types.has('video')) { deny(`media types=[${[...types].join(',')}]`); return; } + callback(true); + return; + } + callback(true); + }); + def.setPermissionCheckHandler((_wc, permission, requestingOrigin, details) => { + if (DENY_PERMISSIONS.has(permission)) { + console.warn(`[asio-diag] permission-check DENIED: ${permission} (deny-list) origin=${requestingOrigin ?? ''}`); + return false; + } + if (!isRendererOrigin(requestingOrigin || '')) { + console.warn(`[asio-diag] permission-check DENIED: ${permission} (non-renderer origin) origin=${requestingOrigin ?? ''}`); + return false; + } + if (permission === 'media') { + // Mirror the request-handler in the synchronous check path so + // navigator.permissions.query() reports the same state we'll + // actually grant: deny camera ('video'), allow audio and the + // type-less display-capture path. Electron's check-handler + // `details` exposes `mediaType` (singular), unlike the + // request-handler's `mediaTypes` (plural array). + const mediaType = (details as { mediaType?: string }).mediaType; + if (mediaType === 'video') { + console.warn(`[asio-diag] permission-check DENIED: media (mediaType=video) origin=${requestingOrigin ?? ''}`); + return false; + } + return true; + } + 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 { + if (process.platform !== 'darwin') { + console.log('[main] ensureMicrophoneAccess: platform !== darwin, skipping'); + 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) { + console.log('[main] ensureMicrophoneAccess: app.isPackaged === false, skipping (NSMicrophoneUsageDescription may be absent)'); + return; + } + try { + const status = systemPreferences.getMediaAccessStatus('microphone'); + console.log(`[main] ensureMicrophoneAccess: getMediaAccessStatus returned '${status}'`); + + if (status === 'granted') { + // TCC database has a grant for this bundle ID, BUT macOS enforces + // the grant against the code signature — a stale entry (keyed to a + // different signature from an earlier build) reports 'granted' here + // while CoreAudio still delivers zeroed input. Log bundle and + // packaging details so the debug log can diagnose signature-mismatch + // vs. genuine grant. + const name = app.getName(); + const ver = app.getVersion(); + const pkg = app.isPackaged; + const resPath = app.getPath('exe'); + console.log(`[main] Microphone access: 'granted' (early return — app="${name}" v${ver} packaged=${pkg} exe="${resPath}")`); + console.log('[main] ensureMicrophoneAccess: returning early — status is granted, no askForMediaAccess call'); + 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. + console.log('[main] Microphone access: status is not-determined, calling askForMediaAccess (system prompt should appear)'); + const granted = await systemPreferences.askForMediaAccess('microphone'); + console.log(`[main] Microphone access ${granted ? 'granted' : 'denied'} by user.`); + + if (!granted) { + // User denied the prompt — log extra context so we can distinguish + // "user clicked Don't Allow" from "prompt never appeared / resolved false". + console.warn('[main] Microphone access: user denied the permission prompt. Input will be silent.'); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.warn(`[main] Microphone access request failed: ${msg}`); + // Log the full error stack if available — the try/catch above may not + // catch a process termination (missing NSMicrophoneUsageDescription), + // but anything that does reach here should be diagnosable. + if (e instanceof Error && e.stack) { + console.warn(`[main] Microphone access error stack: ${e.stack}`); + } + } +} + +async function startup(): Promise { + // 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); + + // Detachable panes: the tray that lists them, and the OS behaviour applied to + // each pane window as the renderer opens it (see did-create-window above). + // Must come after createWindow — both reach the renderer through mainWindow, + // and Tray requires a ready app. + initPaneHosts({ getMainWindow: () => mainWindow }); + // The tray's pane actions are INJECTED, not imported: pane-hosts already imports + // pane-tray (to push the menu), and importing back would make the two modules + // mutually dependent — a require cycle whose loser sees half-initialised exports. + initTray({ + getMainWindow: () => mainWindow, + toggleWindow: togglePaneWindow, + showAll: showAllPaneWindows, + hideAll: hideAllPaneWindows, + hasWindow: hasPaneWindow, + }); + + // Install our application menu (replaces Electron's default so View → + // Zoom In also accepts the unshifted Ctrl+= key — see app-menu.ts). + installAppMenu(); + + // 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', 'nightly']; + 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.feedBackDesktop.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(); +const powerCleanupWired = new WeakSet(); + +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 */ } + destroyTray(); + 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); + } +}); diff --git a/src/main/pane-hosts.ts b/src/main/pane-hosts.ts index a3883de..9bfd0a4 100644 --- a/src/main/pane-hosts.ts +++ b/src/main/pane-hosts.ts @@ -1,197 +1,255 @@ -// Pane pop-out windows. -// -// feedBack core has a pane system (window.feedBack.panes): a plugin's panel — a -// mixer, a camera rig, a readout — popped out of the app into its own window and -// left there. Here it gets the desktop treatment: remembered geometry, off the -// taskbar, minimize-to-tray, and a tray menu that lists every pane (pane-tray.ts). -// -// READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION. -// -// We do NOT create these windows. The renderer opens them with window.open(), and -// Electron's setWindowOpenHandler (main.ts) turns that into a real BrowserWindow -// for us. That is not an accident and it is not laziness: -// -// The pane's element is MOVED into the pop-out window — the actual DOM node, -// adopted across same-origin documents, so it keeps its listeners and its -// closures and goes on running the plugin's own code. To adopt it, the renderer -// needs a handle on the new window's document. A window WE created in the main -// process gives it no such handle. Create the window here and the whole feature -// collapses back into "reimplement the panel and sync it over IPC". -// -// So the renderer opens the window, names its frame `fbpane-`, and we -// recognise it in main.ts's did-create-window and attach the OS behaviour. The -// renderer keeps the DOM link; we supply the window manners. - -import { BrowserWindow, ipcMain, screen } from 'electron'; -import { IPC_PANE_SYNC } from './ipc-channels'; -import { sanitizeWindowBounds, type WindowSizing } from './window-bounds'; -import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager'; -import { setTrayPanes, type TrayPane } from './pane-tray'; - -// The renderer names the frame `fbpane-`. Keep in sync with -// static/panes/pane-window-host.js. -const FRAME_PREFIX = 'fbpane-'; - -// A pane window is small by nature. The main window's 800x600 floor would inflate -// one threefold, which is why sanitizeWindowBounds takes sizing now. -const PANE_SIZING: WindowSizing = { - minWidth: 240, - minHeight: 180, - defaultWidth: 380, - defaultHeight: 560, -}; - -const windows = new Map(); -let getMainWindow: () => BrowserWindow | null = () => null; - -// ── Geometry ──────────────────────────────────────────────────────────────── - -function savedFor(paneId: string): SavedPaneWindow { - return getDesktopConfig().paneWindows?.[paneId] ?? {}; -} - -function persist(paneId: string, patch: SavedPaneWindow): void { - try { - // setDesktopConfig merges shallowly, so paneWindows must be - // read-modify-written or one pane's save would drop every other pane's. - const all = { ...(getDesktopConfig().paneWindows ?? {}) }; - all[paneId] = { ...(all[paneId] ?? {}), ...patch }; - setDesktopConfig({ paneWindows: all }); - } catch (err) { - console.warn(`[panes] failed to persist geometry for ${paneId}:`, err); - } -} - -// ── Adoption ──────────────────────────────────────────────────────────────── - -export function paneIdFromFrameName(frameName: string): string | null { - if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null; - const id = frameName.slice(FRAME_PREFIX.length); - return id ? id : null; -} - -// Called from main.ts's did-create-window when the renderer pops a pane out. -export function adoptPaneWindow(win: BrowserWindow, paneId: string): void { - windows.set(paneId, win); - - const saved = savedFor(paneId); - const restored = sanitizeWindowBounds( - saved.bounds, - screen.getAllDisplays().map((d) => d.workArea), - // The size window.open() asked for is the fallback for a pane that has - // never been opened before; the saved bounds win once it has. - { ...PANE_SIZING, defaultWidth: win.getBounds().width, defaultHeight: win.getBounds().height }, - ); - if (restored.x !== undefined && restored.y !== undefined) { - win.setBounds({ x: restored.x, y: restored.y, width: restored.width, height: restored.height }); - } else { - win.setSize(restored.width, restored.height); - } - win.setMinimumSize(PANE_SIZING.minWidth, PANE_SIZING.minHeight); - if (saved.alwaysOnTop === true) win.setAlwaysOnTop(true); - - // A pane is a companion to the app, not an entry to it: keep it off the taskbar - // so it never masquerades as a second fee[dB]ack. - win.setSkipTaskbar(true); - - // Persist on move/resize, not only on close — a pane window can outlive the app - // in a crash, and the whole point of remembering geometry is that you never - // place it twice. - const save = (): void => { - if (win.isDestroyed()) return; - persist(paneId, { bounds: { ...win.getNormalBounds(), maximized: false } }); - }; - win.on('moved', save); - win.on('resized', save); - - // Minimize sends a pane to the tray, not the taskbar. Panes are small and - // numerous; a taskbar full of them is noise, and the tray already lists them. - // Electron's 'minimize' is not cancellable here (the listener takes no event), - // so we hide right after rather than preventing it — and the window is - // skipTaskbar, so there is no animation to see. - win.on('minimize', () => { - win.hide(); - refreshTray(); - }); - - win.on('closed', () => { - windows.delete(paneId); - refreshTray(); - // No IPC needed to tell the renderer: it opened this window itself and holds - // the WindowProxy, so it already knows — and it has to, because its element - // is inside and must be brought home. - }); - - refreshTray(); -} - -export function closeAllPanes(): void { - // Called when the main window goes. A pane window holds a DOM node belonging to - // the main window's document — with the main window gone there is nothing left - // to dock it back into. And worse: a pane HIDDEN in the tray is still an open - // window, so leaving one behind would stop `window-all-closed` from ever firing - // and the app would linger as an invisible process. - Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); }); - windows.clear(); -} - -// ── Tray ──────────────────────────────────────────────────────────────────── - -// The renderer's last known pane registry. The tray menu is a VIEW of it, never a -// second copy — main has no idea what a pane contains, and does not need one. -let lastSync: TrayPane[] = []; - -function refreshTray(): void { - setTrayPanes(lastSync.map((p) => { - const win = windows.get(p.id); - return { - ...p, - // "open", to the tray, means "has a visible window". A pane docked inside - // the main window is not something the tray can usefully show or hide. - open: !!win && !win.isDestroyed() && win.isVisible(), - }; - })); -} - -export function togglePaneWindow(paneId: string): boolean { - const win = windows.get(paneId); - if (!win || win.isDestroyed()) return false; // not open → only the renderer can open it - if (win.isVisible()) win.hide(); else win.show(); - refreshTray(); - return true; -} - -export function showAllPaneWindows(): void { - windows.forEach((win) => { if (!win.isDestroyed() && !win.isVisible()) win.show(); }); - refreshTray(); -} - -export function hideAllPaneWindows(): void { - windows.forEach((win) => { if (!win.isDestroyed() && win.isVisible()) win.hide(); }); - refreshTray(); -} - -export function hasPaneWindow(paneId: string): boolean { - const win = windows.get(paneId); - return !!win && !win.isDestroyed(); -} - -// ── Wiring ────────────────────────────────────────────────────────────────── - -export function initPaneHosts(deps: { getMainWindow: () => BrowserWindow | null }): void { - getMainWindow = deps.getMainWindow; - - // The renderer pushes its registry whenever a pane is registered, opened or - // closed, so the tray can list panes it otherwise knows nothing about. - // Fire-and-forget: the tray is a view of the renderer's truth. - ipcMain.on(IPC_PANE_SYNC, (_event, panes: unknown) => { - lastSync = Array.isArray(panes) - ? panes.filter((p): p is TrayPane => !!p && typeof p.id === 'string' && typeof p.title === 'string') - : []; - refreshTray(); - }); -} - -export function getMainWindowRef(): BrowserWindow | null { - return getMainWindow(); -} +// Pane pop-out windows. +// +// feedBack core has a pane system (window.feedBack.panes): a plugin's panel — a +// mixer, a camera rig, a readout — popped out of the app into its own window and +// left there. Here it gets the desktop treatment: remembered geometry, off the +// taskbar, minimize-to-tray, and a tray menu that lists every pane (pane-tray.ts). +// +// READ THIS BEFORE "TIDYING UP" THE WINDOW CREATION. +// +// We do NOT create these windows. The renderer opens them with window.open(), and +// Electron's setWindowOpenHandler (main.ts) turns that into a real BrowserWindow +// for us. That is not an accident and it is not laziness: +// +// The pane's element is MOVED into the pop-out window — the actual DOM node, +// adopted across same-origin documents, so it keeps its listeners and its +// closures and goes on running the plugin's own code. To adopt it, the renderer +// needs a handle on the new window's document. A window WE created in the main +// process gives it no such handle. Create the window here and the whole feature +// collapses back into "reimplement the panel and sync it over IPC". +// +// So the renderer opens the window, names its frame `fbpane-`, and we +// recognise it in main.ts's did-create-window and attach the OS behaviour. The +// renderer keeps the DOM link; we supply the window manners. + +import { BrowserWindow, ipcMain, screen } from 'electron'; +import { IPC_PANE_SYNC } from './ipc-channels'; +import { sanitizeWindowBounds, type WindowSizing } from './window-bounds'; +import { getDesktopConfig, setDesktopConfig, type SavedPaneWindow } from './soundfont-manager'; +import { setTrayPanes, type TrayPane } from './pane-tray'; + +// The renderer names the frame `fbpane-`. Keep in sync with +// static/panes/pane-window-host.js. +const FRAME_PREFIX = 'fbpane-'; + +// A pane window is small by nature. The main window's 800x600 floor would inflate +// one threefold, which is why sanitizeWindowBounds takes sizing now. +const PANE_SIZING: WindowSizing = { + minWidth: 240, + minHeight: 180, + defaultWidth: 380, + defaultHeight: 560, +}; + +const windows = new Map(); +let getMainWindow: () => BrowserWindow | null = () => null; + +// ── Geometry ──────────────────────────────────────────────────────────────── + +function savedFor(paneId: string): SavedPaneWindow { + const saved = getDesktopConfig().paneWindows ?? {}; + // Own-property check: a polluted or hand-edited config would otherwise hand back + // a value off the prototype chain for a pane that was never saved at all. + return Object.prototype.hasOwnProperty.call(saved, paneId) ? saved[paneId] : {}; +} + +// A pane id reaches us from the RENDERER (it is the tail of the frame name a +// window.open() supplied), and it is used as a key in the persisted map. So it is +// untrusted input in key position: `__proto__` and friends are not ids, they are a +// way to mutate Object.prototype from a plugin. +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +function isUnsafePaneId(paneId: string): boolean { + return UNSAFE_KEYS.has(paneId); +} + +function persist(paneId: string, patch: SavedPaneWindow): void { + if (isUnsafePaneId(paneId)) return; + try { + // setDesktopConfig merges shallowly, so paneWindows must be + // read-modify-written or one pane's save would drop every other pane's. + // + // Built on a null-prototype object: whatever is in the config file (hand + // edited, corrupt, or written by an older build) cannot smuggle a prototype + // into a map we then write keys onto. + const all: Record = Object.create(null); + const saved = getDesktopConfig().paneWindows ?? {}; + for (const key of Object.keys(saved)) { + if (!isUnsafePaneId(key)) all[key] = saved[key]; + } + all[paneId] = { ...(all[paneId] ?? {}), ...patch }; + setDesktopConfig({ paneWindows: { ...all } }); + } catch (err) { + console.warn(`[panes] failed to persist geometry for ${paneId}:`, err); + } +} + +// setDesktopConfig is writeFileSync + renameSync. Wiring that straight to 'moved' +// and 'resized' means a synchronous disk write for every frame of a drag — on +// macOS, dozens per second, in the main process, where they block everything else. +// Write once the gesture settles instead. +const GEOMETRY_SAVE_DEBOUNCE_MS = 400; +const saveTimers = new Map(); + +function persistSoon(paneId: string, patch: () => SavedPaneWindow | null): void { + clearTimeout(saveTimers.get(paneId)); + saveTimers.set(paneId, setTimeout(() => { + saveTimers.delete(paneId); + const value = patch(); + if (value) persist(paneId, value); + }, GEOMETRY_SAVE_DEBOUNCE_MS)); +} + +// Debouncing means the last move can still be in flight when the window goes. Write +// it out NOW, while the window is alive enough to be measured — otherwise a user who +// nudges a pane and closes it a moment later loses the position they just chose, +// which is exactly the thing remembered geometry exists to prevent. +function flushGeometry(win: BrowserWindow, paneId: string): void { + const timer = saveTimers.get(paneId); + if (timer) { clearTimeout(timer); saveTimers.delete(paneId); } + if (win.isDestroyed()) return; + persist(paneId, { bounds: { ...win.getNormalBounds(), maximized: false } }); +} + +// ── Adoption ──────────────────────────────────────────────────────────────── + +export function paneIdFromFrameName(frameName: string): string | null { + if (!frameName || !frameName.startsWith(FRAME_PREFIX)) return null; + const id = frameName.slice(FRAME_PREFIX.length); + return id ? id : null; +} + +// Called from main.ts's did-create-window when the renderer pops a pane out. +export function adoptPaneWindow(win: BrowserWindow, paneId: string): void { + windows.set(paneId, win); + + const saved = savedFor(paneId); + const restored = sanitizeWindowBounds( + saved.bounds, + screen.getAllDisplays().map((d) => d.workArea), + // The size window.open() asked for is the fallback for a pane that has + // never been opened before; the saved bounds win once it has. + { ...PANE_SIZING, defaultWidth: win.getBounds().width, defaultHeight: win.getBounds().height }, + ); + if (restored.x !== undefined && restored.y !== undefined) { + win.setBounds({ x: restored.x, y: restored.y, width: restored.width, height: restored.height }); + } else { + win.setSize(restored.width, restored.height); + } + win.setMinimumSize(PANE_SIZING.minWidth, PANE_SIZING.minHeight); + if (saved.alwaysOnTop === true) win.setAlwaysOnTop(true); + + // A pane is a companion to the app, not an entry to it: keep it off the taskbar + // so it never masquerades as a second fee[dB]ack. + win.setSkipTaskbar(true); + + // Persist on move/resize, not only on close — a pane window can outlive the app + // in a crash, and the whole point of remembering geometry is that you never + // place it twice. + const save = (): void => { + persistSoon(paneId, () => + win.isDestroyed() ? null : { bounds: { ...win.getNormalBounds(), maximized: false } }); + }; + win.on('moved', save); + win.on('resized', save); + + // Minimize sends a pane to the tray, not the taskbar. Panes are small and + // numerous; a taskbar full of them is noise, and the tray already lists them. + // Electron's 'minimize' is not cancellable here (the listener takes no event), + // so we hide right after rather than preventing it — and the window is + // skipTaskbar, so there is no animation to see. + win.on('minimize', () => { + win.hide(); + refreshTray(); + }); + + // 'close' fires while the window still exists; 'closed' after it is gone. The + // final geometry can only be read from the former. + win.on('close', () => flushGeometry(win, paneId)); + + win.on('closed', () => { + windows.delete(paneId); + refreshTray(); + // No IPC needed to tell the renderer: it opened this window itself and holds + // the WindowProxy, so it already knows — and it has to, because its element + // is inside and must be brought home. + }); + + refreshTray(); +} + +export function closeAllPanes(): void { + // Called when the main window goes. A pane window holds a DOM node belonging to + // the main window's document — with the main window gone there is nothing left + // to dock it back into. And worse: a pane HIDDEN in the tray is still an open + // window, so leaving one behind would stop `window-all-closed` from ever firing + // and the app would linger as an invisible process. + Array.from(windows.values()).forEach((win) => { if (!win.isDestroyed()) win.destroy(); }); + windows.clear(); +} + +// ── Tray ──────────────────────────────────────────────────────────────────── + +// The renderer's last known pane registry. The tray menu is a VIEW of it, never a +// second copy — main has no idea what a pane contains, and does not need one. +let lastSync: TrayPane[] = []; + +function refreshTray(): void { + setTrayPanes(lastSync.map((p) => { + const win = windows.get(p.id); + return { + ...p, + // "open", to the tray, means "has a visible window". A pane docked inside + // the main window is not something the tray can usefully show or hide. + open: !!win && !win.isDestroyed() && win.isVisible(), + }; + })); +} + +// A pane is sent to the tray by MINIMIZING it and then hiding it — and hiding a +// minimized window does not un-minimize it. So show() alone would restore a window +// that is still minimized: present, but not on screen, which reads as the tray +// being broken. Always restore first. +function reveal(win: BrowserWindow): void { + if (win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + win.show(); +} + +export function togglePaneWindow(paneId: string): boolean { + const win = windows.get(paneId); + if (!win || win.isDestroyed()) return false; // not open → only the renderer can open it + if (win.isVisible() && !win.isMinimized()) win.hide(); else reveal(win); + refreshTray(); + return true; +} + +export function showAllPaneWindows(): void { + windows.forEach((win) => { if (!win.isDestroyed()) reveal(win); }); + refreshTray(); +} + +export function hideAllPaneWindows(): void { + windows.forEach((win) => { if (!win.isDestroyed() && win.isVisible()) win.hide(); }); + refreshTray(); +} + +export function hasPaneWindow(paneId: string): boolean { + const win = windows.get(paneId); + return !!win && !win.isDestroyed(); +} + +// ── Wiring ────────────────────────────────────────────────────────────────── + +export function initPaneHosts(deps: { getMainWindow: () => BrowserWindow | null }): void { + getMainWindow = deps.getMainWindow; + + // The renderer pushes its registry whenever a pane is registered, opened or + // closed, so the tray can list panes it otherwise knows nothing about. + // Fire-and-forget: the tray is a view of the renderer's truth. + ipcMain.on(IPC_PANE_SYNC, (_event, panes: unknown) => { + lastSync = Array.isArray(panes) + ? panes.filter((p): p is TrayPane => !!p && typeof p.id === 'string' && typeof p.title === 'string') + : []; + refreshTray(); + }); +} diff --git a/src/main/pane-tray.ts b/src/main/pane-tray.ts index 337575c..229d176 100644 --- a/src/main/pane-tray.ts +++ b/src/main/pane-tray.ts @@ -1,120 +1,135 @@ -// The system tray. -// -// This is the first Tray in the app, and it exists for one reason: a pane you -// popped out is furniture. You want it out of the way while you play and back -// instantly when you don't — without hunting for it behind the main window or in -// a taskbar full of small companions. -// -// The menu is a view of the RENDERER's pane registry (pushed up over pane:sync), -// not a second copy of it. Main never decides what a pane is; it only shows and -// hides windows. A pane the tray doesn't have a window for is toggled by asking -// the renderer to open it — which is what pane:toggle is. -// -// Icon: resolved from dist/main/, next to the compiled JS. build:ts copies it -// there, which is the same trick splash.html/spinner.json use — it means -// `__dirname` works identically in dev and in a packaged asar, with no -// app.isPackaged branch and nothing to add to electron-builder's extraResources. - -import { Menu, Tray, nativeImage, app } from 'electron'; -import * as path from 'path'; -import { IPC_PANE_EVENT_TOGGLE } from './ipc-channels'; -import { togglePaneWindow, showAllPaneWindows, hideAllPaneWindows, hasPaneWindow } from './pane-hosts'; - -export interface TrayPane { - id: string; - title: string; - icon?: string; - open?: boolean; -} - -let tray: Tray | null = null; -let panes: TrayPane[] = []; -let getMainWindow: () => Electron.BrowserWindow | null = () => null; - -function iconPath(): string { - // Windows wants an .ico; macOS and Linux take a PNG. macOS additionally wants - // a monochrome template image, which the 16px PNG is not — so it will render - // in colour there. Acceptable, and preferable to shipping no tray at all; - // a proper …Template.png is a follow-up. - return path.join(__dirname, process.platform === 'win32' ? 'tray.ico' : 'tray.png'); -} - -function showMainWindow(): void { - const win = getMainWindow(); - if (!win || win.isDestroyed()) return; - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); -} - -function buildMenu(): Menu { - const paneItems: Electron.MenuItemConstructorOptions[] = panes.map((p) => ({ - label: (p.icon ? p.icon + ' ' : '') + p.title, - type: 'checkbox', - checked: p.open === true, - click: () => { - // If we already own a window for this pane, showing/hiding it is a - // main-process job and instant. If we don't, only the renderer can - // decide what opening it means (it might belong in the dock), so ask. - if (hasPaneWindow(p.id)) { togglePaneWindow(p.id); return; } - const win = getMainWindow(); - if (win && !win.isDestroyed()) win.webContents.send(IPC_PANE_EVENT_TOGGLE, { paneId: p.id }); - }, - })); - - const template: Electron.MenuItemConstructorOptions[] = [ - { label: 'Show fee[dB]ack', click: showMainWindow }, - { type: 'separator' }, - ]; - - if (paneItems.length) { - template.push({ label: 'Panes', enabled: false }); - template.push(...paneItems); - template.push({ type: 'separator' }); - template.push({ label: 'Show all panes', click: showAllPaneWindows }); - template.push({ label: 'Hide all panes', click: hideAllPaneWindows }); - } else { - template.push({ label: 'No panes', enabled: false }); - } - - template.push({ type: 'separator' }); - template.push({ label: 'Quit fee[dB]ack', click: () => app.quit() }); - - return Menu.buildFromTemplate(template); -} - -// Called by pane-hosts whenever the registry or the window state changes. The -// whole menu is rebuilt — it is a handful of items, built only on user-visible -// state changes, and never on a playback path. -export function setTrayPanes(next: TrayPane[]): void { - panes = next; - if (!tray || tray.isDestroyed()) return; - tray.setContextMenu(buildMenu()); -} - -export function initTray(deps: { getMainWindow: () => Electron.BrowserWindow | null }): void { - if (tray) return; - getMainWindow = deps.getMainWindow; - - const image = nativeImage.createFromPath(iconPath()); - if (image.isEmpty()) { - // A Tray built from an empty image is an invisible tray: the menu exists - // but the user can never reach it. Fail loudly and simply go without — - // panes still pop out, they just aren't tray-managed. - console.warn(`[panes] tray icon missing or unreadable at ${iconPath()} — running without a tray`); - return; - } - - tray = new Tray(image); - tray.setToolTip('fee[dB]ack'); - tray.setContextMenu(buildMenu()); - // Left-click is the fast path back to the app, which is what a user reaching - // for the tray almost always wants. (No-op on Linux, where most desktops give - // a left-click the context menu anyway.) - tray.on('click', showMainWindow); -} - -export function destroyTray(): void { - if (tray && !tray.isDestroyed()) tray.destroy(); - tray = null; -} +// The system tray. +// +// This is the first Tray in the app, and it exists for one reason: a pane you +// popped out is furniture. You want it out of the way while you play and back +// instantly when you don't — without hunting for it behind the main window or in +// a taskbar full of small companions. +// +// The menu is a view of the RENDERER's pane registry (pushed up over pane:sync), +// not a second copy of it. Main never decides what a pane is; it only shows and +// hides windows. A pane the tray doesn't have a window for is toggled by asking +// the renderer to open it — which is what pane:toggle is. +// +// Icon: resolved from dist/main/, next to the compiled JS. build:ts copies it +// there, which is the same trick splash.html/spinner.json use — it means +// `__dirname` works identically in dev and in a packaged asar, with no +// app.isPackaged branch and nothing to add to electron-builder's extraResources. + +import { Menu, Tray, nativeImage, app } from 'electron'; +import * as path from 'path'; +import { IPC_PANE_EVENT_TOGGLE } from './ipc-channels'; + +export interface TrayPane { + id: string; + title: string; + icon?: string; + open?: boolean; +} + +// What the tray needs from the pane host, INJECTED rather than imported. +// +// pane-hosts imports setTrayPanes from this file, so importing back from there +// would make the two modules mutually dependent — and a require cycle in the main +// process is not a style question: whichever module loads second sees the other's +// exports half-initialised, which fails at whatever moment the graph happens to +// resolve in. One direction only: pane-hosts → pane-tray, and the actions come in +// through initTray(). +export interface TrayPaneActions { + getMainWindow: () => Electron.BrowserWindow | null; + toggleWindow: (paneId: string) => boolean; + showAll: () => void; + hideAll: () => void; + hasWindow: (paneId: string) => boolean; +} + +let tray: Tray | null = null; +let panes: TrayPane[] = []; +let actions: TrayPaneActions | null = null; + +function iconPath(): string { + // Windows wants an .ico; macOS and Linux take a PNG. macOS additionally wants + // a monochrome template image, which the 16px PNG is not — so it will render + // in colour there. Acceptable, and preferable to shipping no tray at all; + // a proper …Template.png is a follow-up. + return path.join(__dirname, process.platform === 'win32' ? 'tray.ico' : 'tray.png'); +} + +function showMainWindow(): void { + const win = actions?.getMainWindow() ?? null; + if (!win || win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} + +function buildMenu(): Menu { + const paneItems: Electron.MenuItemConstructorOptions[] = panes.map((p) => ({ + label: (p.icon ? p.icon + ' ' : '') + p.title, + type: 'checkbox', + checked: p.open === true, + click: () => { + // If we already own a window for this pane, showing/hiding it is a + // main-process job and instant. If we don't, only the renderer can + // decide what opening it means (it might belong in the dock), so ask. + if (actions?.hasWindow(p.id)) { actions.toggleWindow(p.id); return; } + const win = actions?.getMainWindow() ?? null; + if (win && !win.isDestroyed()) win.webContents.send(IPC_PANE_EVENT_TOGGLE, { paneId: p.id }); + }, + })); + + const template: Electron.MenuItemConstructorOptions[] = [ + { label: 'Show fee[dB]ack', click: showMainWindow }, + { type: 'separator' }, + ]; + + if (paneItems.length) { + template.push({ label: 'Panes', enabled: false }); + template.push(...paneItems); + template.push({ type: 'separator' }); + template.push({ label: 'Show all panes', click: () => actions?.showAll() }); + template.push({ label: 'Hide all panes', click: () => actions?.hideAll() }); + } else { + template.push({ label: 'No panes', enabled: false }); + } + + template.push({ type: 'separator' }); + template.push({ label: 'Quit fee[dB]ack', click: () => app.quit() }); + + return Menu.buildFromTemplate(template); +} + +// Called by pane-hosts whenever the registry or the window state changes. The +// whole menu is rebuilt — it is a handful of items, built only on user-visible +// state changes, and never on a playback path. +export function setTrayPanes(next: TrayPane[]): void { + panes = next; + if (!tray || tray.isDestroyed()) return; + tray.setContextMenu(buildMenu()); +} + +export function initTray(deps: TrayPaneActions): void { + if (tray) return; + actions = deps; + + const image = nativeImage.createFromPath(iconPath()); + if (image.isEmpty()) { + // A Tray built from an empty image is an invisible tray: the menu exists + // but the user can never reach it. Fail loudly and simply go without — + // panes still pop out, they just aren't tray-managed. + console.warn(`[panes] tray icon missing or unreadable at ${iconPath()} — running without a tray`); + return; + } + + tray = new Tray(image); + tray.setToolTip('fee[dB]ack'); + tray.setContextMenu(buildMenu()); + // Left-click is the fast path back to the app, which is what a user reaching + // for the tray almost always wants. (No-op on Linux, where most desktops give + // a left-click the context menu anyway.) + tray.on('click', showMainWindow); +} + +export function destroyTray(): void { + if (tray && !tray.isDestroyed()) tray.destroy(); + tray = null; +}