Files
feedBack-desktop/src/main/main.ts
T
2a4396b7b7
Ship CI / CI (push) Has been cancelled
Addon CI / addon (arm64, macos-14, mac) (push) Has been cancelled
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Has been cancelled
Addon CI / addon (x64, windows-latest, win) (push) Has been cancelled
Linux AppImage self-update on the nightly channel (#119)
* feat(update): Linux AppImage self-update on the nightly channel

Adds a self-update engine for the Linux AppImage build. There's no
Velopack pipeline for Linux (Windows/macOS use it, Linux doesn't), so
this is a small, purpose-built GitHub-releases checker instead:

- On the nightly channel, compares the commit baked into the running
  build (dist/main/build-info.json, written at build time) against the
  published nightly's target_commitish. A mismatch means the running
  build is behind, so it's offered as an update — this sidesteps the
  fact that the AppImage's filename and app.getVersion() never change
  between nightly builds, so semver comparison can't detect a new one.
- The check returns immediately and the ~1.5GB download runs in the
  background with live progress (a new update:progress IPC event), so
  the UI never blocks or freezes waiting on it.
- The download streams straight to disk (no buffering the whole file in
  memory) and is swapped in with an atomic rename next to the running
  AppImage. The stale-generation check (a channel switch or new check
  invalidating an in-flight download) runs before that swap, and a
  failed or superseded download always cleans up its temp file.
- Applying the update spawns the (already-swapped-in) AppImage as a
  detached process and waits for a real 'spawn' confirmation before
  quitting this one, rather than assuming success — child_process.spawn
  can fail asynchronously, and quitting on an unconfirmed relaunch could
  leave the user with nothing running.
- The pure idle/staged/download decision is split into
  linux-update-decision.ts with a small truth-table test, and every
  main-process decision point (and the equivalent renderer-side
  actions, in the companion feedBack PR) is traced through a new
  update:diag IPC event that lands in the app's existing "Export
  Diagnostics" console-capture bundle — this is how the handful of real
  bugs below were actually root-caused, from real device captures
  rather than guesswork.

Also removes a forgotten, dead second implementation of the
update-channel UI (src/renderer/screen.js's
setupUpdateChannelControls() + its markup in settings.html), left over
from before this work discovered the real, visible System-tab update
UI lives in the feedBack repo. It was still wired up in the
audio_engine plugin's own settings panel and silently called
setChannel() with a stale channel value every time that panel
rendered — invisibly corrupting the real UI's state. This was the
actual root cause of several rounds of flaky, hard-to-reproduce
on-device behavior (a stuck "unsupported" warning, downloads starting
without an explicit check, etc.) chased down via the diagnostic
tracing above; once found, no other logic needed to change.

Dev tooling only, not used by CI: forces --platform linux/amd64 in the
local Docker build wrapper (the Linux target is x86_64-only end to
end — needed on Apple Silicon, where Rosetta chokes on a foreign-arch
binary inside an otherwise-native container) and adds a SLOPSMITH_REPO
override so a contributor without push access to the core repo can
bundle a fork branch for a local test build.

Verified end-to-end on a Steam Deck across many build/deploy rounds:
fresh launch, channel selection, check, background download with live
progress, atomic swap, and relaunch onto the new build — confirmed via
a real Export Diagnostics capture showing a clean, fully-accounted-for
trace with zero orphaned state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(update): fail safe when nightly release isn't pinned to a commit SHA

GitHub sets a release's target_commitish to whatever it was published
against — a 40-char SHA only if pinned, otherwise a branch name like
"main". The Linux update decision compares it SHA-vs-SHA, so a branch
name would never match the baked SHA and would re-download the ~1.5GB
AppImage on every check forever, never reaching idle. Add isCommitSha()
(pure, unit-tested) and have checkNowLinux() surface an error instead of
entering that loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 12:15:55 +02:00

1522 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Slopsmith Desktop — Electron Main Process
// Manages: window lifecycle, Python subprocess, audio engine bridge, plugin management
// ── Velopack startup hook ─────────────────────────────────────────────────
// MUST run before ANY other side-effecting code (crashReporter, app event
// listeners, the rest of the imports below). When Windows invokes
// `Update.exe` with `--veloapp-install`/`--veloapp-updated`/`--veloapp-firstrun`
// it relaunches our exe with those flags; `VelopackApp.build().run()` is what
// detects them, runs the appropriate hook, and exits. If the hook doesn't
// run first the bootstrapper silently breaks install/upgrade flows.
// On macOS the hook just returns (no-op).
// Linux has no Velopack pipeline (electron-builder AppImage/deb only), so the
// native module is never needed there — skip the require entirely so loading
// it on an unsupported platform can never crash startup. On win/mac a load
// failure is also caught: a broken updater is recoverable, a dead app is not.
if (process.platform !== 'linux') {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { VelopackApp } = require('velopack') as typeof import('velopack');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { maybeUninstallLegacyNsis } = require('./nsis-migration') as typeof import('./nsis-migration');
VelopackApp.build()
// Fires during MSI install (the InstallHookDeferred custom
// action in vpk's WiX template, post-InstallFiles). Detects a
// legacy NSIS install at HKLM\...\Uninstall\Slopsmith, runs
// its QuietUninstallString, and restores the Velopack stub
// that NSIS deletes as part of its cleanup. No-op on machines
// without the legacy install. 30-second hard budget — runs
// synchronously with a sync registry-poll, well within it.
.onAfterInstallFastCallback(() => {
try {
maybeUninstallLegacyNsis();
} catch (err) {
console.error('[main] NSIS cleanup hook failed:', err);
}
})
.run();
} catch (err) {
// Never crash over this — a launchable app beats a dead one. But in a
// packaged build a hook failure means install/update lifecycle flags
// won't be handled, so surface it with a dialog instead of a console
// line nobody reads. In dev/unpackaged builds the hook is a harmless
// no-op, so a logged warning is enough there.
console.error('[main] Velopack startup hook failed:', err);
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app, dialog } = require('electron');
if (app.isPackaged) {
dialog.showErrorBox(
'fee[dB]ack update system error',
'The Velopack updater failed to initialize. fee[dB]ack will still '
+ 'run, but automatic updates may not work until it is reinstalled.'
+ `\n\n${String(err)}`,
);
}
}
}
// ──────────────────────────────────────────────────────────────────────────
import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, powerSaveBlocker, systemPreferences, 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 (<appData>/feedback-desktop) is used
// throughout. It does NOT change the user-facing brand (productName 'fee[dB]ack');
// with the name pinned, the path-hostile brackets never reach the filesystem.
app.setName('feedback-desktop');
// One-time copy of an existing legacy userData folder into the new one, so
// testers don't start fresh after the rename. MUST run before BOTH crashReporter
// (which creates <userData>/Crashpad) AND requestSingleInstanceLock() (which
// writes a SingletonLock into userData) — the migration gate is "new userData
// doesn't exist yet", so anything that creates it first would silently skip the
// migration and start the upgraded user fresh.
migrateUserDataIfNeeded();
// Acquire the single-instance lock now so the primary-only deferred-reset cleanup
// runs ONLY in the instance that will actually boot: a losing second instance
// must not consume the pending-reset manifest while the primary still holds
// Chromium / Crashpad files open. requestSingleInstanceLock() must be called
// exactly once; the lock branch at the bottom of this file reuses this result.
// SLOPSMITH_ALLOW_MULTIPLE=1 opts out (two builds side-by-side).
const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1';
const hasSingleInstanceLock = allowMultipleInstances || app.requestSingleInstanceLock();
if (hasSingleInstanceLock) {
// Apply any reset deletions deferred from a previous "Reset configuration"
// run, before crashReporter / any BrowserWindow reopens Chromium state &
// Crashpad, so those held-open paths can actually be removed.
consumePendingResetIfNeeded();
}
// Enable Electron's Crashpad to capture native crashes (incl. VST/JUCE C++
// access violations) into <userData>/Crashpad/reports/ as .dmp files. Must
// run before app.whenReady(). uploadToServer:false keeps dumps local — they
// can be inspected with WinDbg / minidump-stackwalk.
crashReporter.start({
productName: 'feedback-desktop',
companyName: 'feedback',
submitURL: '',
uploadToServer: false,
compress: false,
});
import { startPython, stopPython, waitForPython, getPythonPort, StartupStatus, restartPython, getLanUrls, getConfigDir } from './python';
import { runConfigMigrations } from './config-migrations';
import { registerMaintenanceHandlers } from './config-reset';
import {
IPC_STARTUP_STATUS,
IPC_STARTUP_GET_STATUS,
IPC_STARTUP_REQUEST_STATUS,
IPC_UPDATE_GET_STATUS,
IPC_UPDATE_SET_CHANNEL,
IPC_UPDATE_CHECK_NOW,
IPC_UPDATE_APPLY,
IPC_POWER_SET_SCREEN_AWAKE,
IPC_WINDOW_GET_START_FULLSCREEN,
IPC_WINDOW_SET_START_FULLSCREEN,
} 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,
} 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<string>(
(existing || '').split(',').map((f) => f.trim()).filter(Boolean),
);
features.add('WebRTCPipeWireCapturer');
app.commandLine.appendSwitch('enable-features', Array.from(features).join(','));
// chrome-sandbox SUID abort fix (issue #438). From an AppImage the bundled
// chrome-sandbox is inert — the squashfs is mounted nosuid so its SUID bit
// can't take effect — and Chromium falls back to the unprivileged-userns
// sandbox. On kernels where userns is disabled (Debian/Arch hardening) or
// AppArmor-restricted (Ubuntu 24.04+), BOTH sandbox paths fail and Electron
// ABORTS before any app code runs. We can't enable userns or make a nosuid
// mount honor SUID, so on exactly that combination — running as an AppImage
// AND no usable userns — fall back to --no-sandbox so the app launches at
// all. The sandbox stays ON for .deb installs, dev runs, and every
// userns-capable system; this only triggers where Electron would otherwise
// die. Set before app.whenReady() so it's read during Chromium init.
if (process.env.APPIMAGE && !linuxUnprivilegedUsernsAvailable()) {
console.warn(
'[startup] AppImage on a kernel without usable unprivileged user '
+ 'namespaces — launching with --no-sandbox so the app can start '
+ '(the Chromium process sandbox is disabled). To keep the sandbox, '
+ 'install the .deb, enable kernel.unprivileged_userns_clone=1, or '
+ 'run the AppImage on a userns-capable kernel. (issue #438)',
);
app.commandLine.appendSwitch('no-sandbox');
}
}
// A failing GPU must degrade, not kill the app.
//
// When the GPU process can't launch or keeps crashing, Chromium's default is to
// give up and FATAL-abort the whole browser process — the user sees the app
// vanish ("GPU process isn't usable. Goodbye." / gpu_data_manager_impl_private
// .cc). We hit exactly this: a machine with a flaky GPU stack (bad driver, a
// headless/VM session, a transient reset) took the app down mid-song, and it is
// the likely reason behind the "gig always defaults to the classic 2D highway"
// reports — those machines are one GPU hiccup away from a crash, not just a
// fallback. Disabling the crash LIMIT tells Chromium to keep the browser alive
// and fall back to software rendering instead of aborting. The 3D highway then
// degrades to the 2D one (or runs slow under SwiftShader), which is a far better
// outcome than the whole app dying. Set before app.whenReady() so Chromium
// reads it during init. Software rasterization stays enabled (we never pass
// --disable-software-rasterizer), so the fallback path exists to land on.
app.commandLine.appendSwitch('disable-gpu-process-crash-limit');
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:<port>/ and paint — a minute is very generous.
let splashSafetyTimer: ReturnType<typeof setTimeout> | null = null;
const SPLASH_RENDERER_DEADLINE_MS = 60_000;
// Milliseconds to keep the splash visible after a terminal error so the
// renderer has time to paint the final message before the window closes.
const SPLASH_CLOSE_DELAY_MS = 300;
let startupStatusSnapshot: StartupStatus = {
running: true,
phase: 'booting',
message: 'Starting fee[dB]ack...',
currentPlugin: '',
loaded: 0,
total: 0,
error: null,
};
function getResourcesPath(): string {
return app.isPackaged
? path.join(process.resourcesPath)
: path.join(__dirname, '..', '..');
}
function publishStartupStatus(status: Partial<StartupStatus>): void {
startupStatusSnapshot = { ...startupStatusSnapshot, ...status };
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.webContents.send(IPC_STARTUP_STATUS, startupStatusSnapshot);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IPC_STARTUP_STATUS, startupStatusSnapshot);
}
}
// Programmatically dismiss the splash. Idempotent. Sets splashDismissing so
// the 'close' handler below doesn't mistake this for a user-initiated quit,
// and clears the safety timer so it can't fire after we're done.
function dismissSplash(): void {
splashDismissing = true;
if (splashSafetyTimer) {
clearTimeout(splashSafetyTimer);
splashSafetyTimer = null;
}
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close();
}
}
// Terminal failure path: the renderer never painted, so the app is unusable.
// Two callers race here — the did-fail-load give-up branch (load retries
// exhausted) fires within ~30 s, while the splashSafetyTimer covers the case
// where the load silently hangs (server accepts the socket but never responds,
// so neither did-finish-load nor did-fail-load ever fires). Whichever fires
// first wins: dismissSplash() sets splashDismissing, so the same guard that
// makes dismissSplash idempotent short-circuits the second caller here too —
// no double error dialog. Surface the failure, tear the splash down, and quit.
function failRendererStartup(reason: string): void {
if (appQuitting || splashDismissing) return;
publishStartupStatus({ message: reason, phase: 'error', running: false });
if (mainWindow && !mainWindow.isDestroyed()) {
dialog.showErrorBox(
'fee[dB]ack failed to start',
'The app window did not finish loading. Please restart fee[dB]ack. '
+ 'If the problem persists, check the backend logs.',
);
}
dismissSplash();
app.quit();
}
function createSplashWindow(): void {
splashWindow = new BrowserWindow({
width: 560,
height: 360,
alwaysOnTop: true,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
frame: false,
show: true,
title: 'fee[dB]ack',
backgroundColor: '#050508',
webPreferences: {
preload: path.join(__dirname, 'splash-preload.js'),
nodeIntegration: false,
contextIsolation: true,
// sandbox must be false so the preload script can require('electron')
// (ipcRenderer). contextIsolation: true keeps the renderer isolated.
sandbox: false,
},
});
splashWindow.loadFile(path.join(__dirname, 'splash.html'));
// Treat a user-initiated close (Alt+F4 / Cmd+W) before the renderer paints
// as an explicit quit, so they are never stuck staring at the splash.
// preventDefault() keeps the splash visible while app.quit() propagates
// through before-quit → will-quit and tears the rest of startup down.
splashWindow.on('close', (event) => {
const currentPhase = startupStatusSnapshot.phase;
// A user-initiated close (Alt+F4 / Cmd+W) before startup finishes is a
// quit. But once the renderer has painted we dismiss the splash via
// dismissSplash() while the backend is only at core-ready (plugins
// still installing in the background, streamed to the renderer) — that
// intentional dismissal sets splashDismissing and must NOT quit.
if (!appQuitting && !splashDismissing && currentPhase !== 'complete' && currentPhase !== 'error') {
event.preventDefault();
app.quit();
}
});
splashWindow.on('closed', () => {
splashWindow = null;
});
}
// Shared by the main BrowserWindow and any same-origin popup spawned via
// window.open (see setWindowOpenHandler / popupOverrideOptions below).
// Single source of truth so a future security-sensitive change (preload
// path, sandbox, webSecurity, isolation) can't update one path and leave
// the other diverged.
// - sandbox: false is required for the preload to use require('electron').
// - webSecurity: false lets the renderer load mixed-origin assets from
// the localhost Python server.
const rendererWebPreferences: Electron.WebPreferences = {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: false,
};
// Off-origin sub-frame origins the renderer is allowed to embed. The
// will-frame-navigate guards below block every other off-origin sub-frame so
// a remote iframe can't ride the privileged preload, but the tutorials plugin
// legitimately embeds YouTube. This is safe only because preload.ts now gates
// its IPC bridge to the main frame — an allow-listed embed frame loads with no
// 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),
);
const startFullscreen = getDesktopConfig().startFullscreen === true;
mainWindow = new BrowserWindow({
x: restored.x,
y: restored.y,
width: restored.width,
height: restored.height,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
fullscreen: startFullscreen,
title: 'fee[dB]ack',
backgroundColor: '#0f172a', // slate-900 to match Slopsmith UI
webPreferences: rendererWebPreferences,
});
if (restored.maximized && !startFullscreen) mainWindow.maximize();
// Persist geometry on close. getNormalBounds() so a maximized session
// saves the underlying windowed size, restored + re-maximized next launch.
// Fullscreen is NOT auto-persisted from the live window state (launching
// straight into fullscreen is jarring, especially on macOS); it launches
// fullscreen ONLY when the user opts in via Settings → System → "Start in
// fullscreen" (config.startFullscreen, applied above). We save geometry 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' });
}
}
// Steam Deck (and Big Picture / "gamepad UI") ships with NO registered default
// web browser, so `shell.openExternal(httpUrl)` → xdg-open falls back to the KDE
// Discover store (a Firefox-install prompt) instead of opening the page. That
// broke "Connect with tone3000" and every other external link on the Deck. When
// we detect that we're running under Steam's gamepad UI, route web links through
// Steam's own overlay browser via the `steam://openurl/` handler, which works in
// that mode (and can still reach the OAuth callback on 127.0.0.1). Gate on the
// Steam env signals so ordinary desktop Linux (where a real browser exists) is
// untouched. Set by the Steam client: `SteamDeck=1` (Deck hardware),
// `SteamGamepadUI=1` (gamepad/Big-Picture UI where there is no browser).
const RUNNING_UNDER_STEAM_GAMEPAD_UI =
process.platform === 'linux' &&
(process.env.SteamGamepadUI === '1' || process.env.SteamDeck === '1');
// 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.
if (RUNNING_UNDER_STEAM_GAMEPAD_UI) {
// Open in Steam's overlay browser. If Steam isn't handling steam://
// (e.g. launched outside Steam), fall back to the normal OS opener so
// we never silently drop the link.
shell.openExternal(`steam://openurl/${parsed.href}`).catch(() => {
shell.openExternal(parsed.href).catch(() => { /* user dismissed / system error */ });
});
return;
}
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<void> {
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<void> {
// Debug logging first so everything below is captured. SLOPSMITH_SANDBOX_DEBUG
// gates the addon's VST_TRACE — flip it whenever debug is *requested*, even
// if the log file couldn't be opened, so addon tracing isn't silently lost.
// The addon caches the var on first read, so it must be set before
// initAudioBridge() loads the .node.
const debugLogPath = initDebugLogging();
if (isDebugEnabled()) {
process.env.SLOPSMITH_SANDBOX_DEBUG = '1';
}
if (debugLogPath) {
console.log(`[main] Debug logging enabled → ${debugLogPath}`);
}
console.log('[main] Starting Slopsmith Desktop...');
// Register startup status IPC handlers before creating the splash window
// so the splash preload's immediate startup:requestStatus is handled.
ipcMain.handle(IPC_STARTUP_GET_STATUS, () => startupStatusSnapshot);
ipcMain.on(IPC_STARTUP_REQUEST_STATUS, (event) => {
event.sender.send(IPC_STARTUP_STATUS, startupStatusSnapshot);
});
createSplashWindow();
publishStartupStatus({ message: 'Starting backend service...', phase: 'booting', running: true });
// Run config-schema migrations against the active backend CONFIG_DIR before
// the backend starts. This replaces "delete the config folder before
// upgrading" with targeted, idempotent, fail-soft migrations. Logging the
// resolved CONFIG_DIR here also closes the visibility gap around the silent
// Linux ~/.local/share/slopsmith shared-config override (python.ts getConfigDir).
try {
const activeConfigDir = getConfigDir();
console.log(`[main] Active CONFIG_DIR: ${activeConfigDir}`);
runConfigMigrations(activeConfigDir, app.getVersion(), new Date().toISOString());
} catch (err) {
console.warn('[main] config migrations failed (continuing):', err);
}
// Register the "Reset / repair configuration" IPC handlers (Settings panel).
// Pass the window getter so the destructive-reset confirmation is a native,
// main-process modal (the renderer bridge is reachable by plugin scripts, so
// a renderer-only confirm is not a sufficient gate).
registerMaintenanceHandlers(() => mainWindow);
// Start Python server (Slopsmith backend)
startPython();
// Prompt for macOS microphone access before the engine opens its native
// CoreAudio input device (see ensureMicrophoneAccess). Awaited so the TCC
// grant lands before initAudioBridge() → audio.init() touches the input.
await ensureMicrophoneAccess();
// Initialize audio engine (JUCE native addon).
initAudioBridge();
// Initialize plugin manager IPC handlers
initPluginManager();
// Initialize soundfont manager IPC handlers (Audio Quality preference)
initSoundfontManager(() => mainWindow);
// Wait for Python server to be ready; null means the backend failed to start.
const port = await waitForPython().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error('[main] Backend failed to start:', message);
publishStartupStatus({ message: `Backend failed to start: ${message}`, phase: 'error', running: false });
return null;
});
if (port === null) {
await new Promise((resolve) => setTimeout(resolve, SPLASH_CLOSE_DELAY_MS));
if (splashWindow && !splashWindow.isDestroyed()) splashWindow.close();
app.quit();
return;
}
console.log(`[main] Python server ready on port ${port}`);
publishStartupStatus({ message: 'Backend ready. Opening app window...', phase: 'core-ready', running: true });
// Permission handlers must be installed before the renderer loads so
// its first permission request hits our policy, not Chromium's
// default. We deferred until now because the policy is scoped to the
// exact renderer port — a stray navigation to another local service
// on a different port must not inherit the trusted-renderer grant.
installRendererPermissions(port);
// Create the main window
createWindow(port);
// 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,
});
// 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. buildSha/coreSha come from dist/main/build-info.json (baked
// at build time — see build-common.sh) and are the same two SHAs the
// Linux update decision compares against the published nightly; exposed
// here too so the renderer's diagnostic snapshot can report exactly
// which commit of each repo (desktop shell + bundled core) is running.
ipcMain.handle('app:getInfo', () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
platform: process.platform,
resourcesPath: getResourcesPath(),
...updateManager.readBuildInfo(),
}));
// 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() };
});
ipcMain.handle(IPC_WINDOW_GET_START_FULLSCREEN, () => getDesktopConfig().startFullscreen === true);
ipcMain.handle(IPC_WINDOW_SET_START_FULLSCREEN, (_event, on: unknown) => {
const value = on === true;
setDesktopConfig({ startFullscreen: value });
// Live-apply so the toggle is responsive, not silent-until-relaunch.
// Works on the first toggle on Windows/Linux. On macOS the FIRST enter on
// a window created windowed is dropped by AppKit (its native-fullscreen
// state machine isn't engaged until the window has been fullscreen once —
// creating with `fullscreen: true` engages it), so there it takes effect
// on next launch instead; the core Settings note tells macOS users that.
// Reliable live both ways once the window has entered fullscreen once.
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isFullScreen() !== value) {
mainWindow.setFullScreen(value);
}
return value;
});
// 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);
}
// Surface GPU / utility subprocess failures instead of letting them vanish
// silently. Paired with --disable-gpu-process-crash-limit above: the app now
// SURVIVES a dead GPU process (falling back to software rendering), so the only
// remaining signal that it happened is this log — which is what a "the highway
// is 2D / the app was crashing" report needs to be diagnosable. `child-process-
// gone` covers the GPU and utility processes; `render-process-gone` is the
// renderer itself. Log-only: none of these should tear the app down.
app.on('child-process-gone', (_event, details) => {
if (details.type === 'GPU' || details.reason !== 'clean-exit') {
console.warn(
`[gpu] ${details.type} process gone: reason=${details.reason}`
+ (details.exitCode != null ? ` exitCode=${details.exitCode}` : '')
+ (details.type === 'GPU'
? ' — the app stays up on software rendering (the 3D highway degrades to 2D).'
: ''),
);
}
});
app.on('render-process-gone', (_event, _wc, details) => {
console.warn(`[renderer] render process gone: reason=${details.reason}`
+ (details.exitCode != null ? ` exitCode=${details.exitCode}` : ''));
});
app.on('window-all-closed', () => {
shutdown();
app.quit();
});
app.on('before-quit', () => {
appQuitting = true;
shutdown();
});
// Screen wake lock (got-feedback/feedback#686). The single OS powerSaveBlocker is
// held while at least one renderer wants the screen awake (a song is playing in
// it). Renderers are refcounted in a set so a multi-window setup (the main
// window plus a same-origin popout) stays correct — one window pausing must not
// drop the blocker while another is still playing.
let powerBlockerId: number | null = null;
const powerAwakeRenderers = new Set<Electron.WebContents>();
const powerCleanupWired = new WeakSet<Electron.WebContents>();
function syncPowerBlocker(): void {
if (powerAwakeRenderers.size > 0) {
if (powerBlockerId === null || !powerSaveBlocker.isStarted(powerBlockerId)) {
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep');
}
} else if (powerBlockerId !== null) {
if (powerSaveBlocker.isStarted(powerBlockerId)) powerSaveBlocker.stop(powerBlockerId);
powerBlockerId = null;
}
}
function setRendererScreenAwake(wc: Electron.WebContents, keep: boolean): void {
if (keep) {
powerAwakeRenderers.add(wc);
// Drop this renderer's hold if it reloads, crashes, or its window closes
// before sending setScreenAwake(false), so the blocker can't outlive its
// playback. Listeners are persistent (.on) so repeated reloads/crashes on
// the same WebContents keep cleaning up; wired once per WebContents so
// play/pause cycles don't pile up duplicates.
if (!powerCleanupWired.has(wc)) {
powerCleanupWired.add(wc);
const drop = () => { powerAwakeRenderers.delete(wc); syncPowerBlocker(); };
wc.on('did-start-loading', drop); // reload / navigation
wc.on('render-process-gone', drop); // renderer crash (may recur)
wc.on('destroyed', drop); // window closed
}
} else {
powerAwakeRenderers.delete(wc);
}
syncPowerBlocker();
}
function shutdown(): void {
try {
console.log('[main] Shutting down...');
} catch { /* console may already be gone mid-teardown */ }
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);
}
});