// Electron entry: runs the Express + WebSocket server in-process, then opens // the Master view in a 1280x800 window. The master client acts as the audio // source; controllers/panels on other devices keep working over the LAN // because the server still binds 0.0.0.0. import { app, BrowserWindow, Menu, session, shell } from 'electron'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { mkdirSync } from 'node:fs'; import 'dotenv/config'; // AbortErrors from undici/node-fetch can escape async route handlers when a // client disconnects mid-stream (station switch). They are benign — the socket // is already gone. Log everything else so genuine bugs are still visible. process.on('unhandledRejection', (reason) => { if (reason?.name === 'AbortError') return; console.error('[electron] unhandledRejection:', reason); }); const __dirname = dirname(fileURLToPath(import.meta.url)); // Pick data location BEFORE importing the server so its module-level path // constants (DB, image root) resolve against the right directory. const repoRoot = resolve(__dirname, '..'); const dataRoot = app.isPackaged ? app.getPath('userData') // %APPDATA%/ on Windows, ~/.config/... on Linux : repoRoot; // dev: keep using the repo's data/ folder const dbPath = resolve(dataRoot, 'data', 'db', 'oradio.sqlite'); const imageRoot = resolve(dataRoot, 'data', 'images'); mkdirSync(dirname(dbPath), { recursive: true }); mkdirSync(imageRoot, { recursive: true }); process.env.DB_PATH = process.env.DB_PATH || dbPath; process.env.ORADIO_IMAGE_ROOT = process.env.ORADIO_IMAGE_ROOT || imageRoot; process.env.PORT = process.env.PORT || '4173'; // Bind locally by default; flip to 0.0.0.0 via env if remote controllers // need to reach this instance over the LAN. process.env.HOST = process.env.HOST || '0.0.0.0'; process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'electron-default-change-me'; process.env.ADMIN_BOOTSTRAP_USER = process.env.ADMIN_BOOTSTRAP_USER || 'admin'; process.env.ADMIN_BOOTSTRAP_PASSWORD = process.env.ADMIN_BOOTSTRAP_PASSWORD || 'changeme'; let serverHandle = null; let mainWindow = null; async function bootServer() { const { startServer } = await import('../server/start.js'); serverHandle = await startServer(); return serverHandle; } // Inject permissive CORS headers on cross-origin MEDIA responses so the // renderer's AnalyserNode can read PCM from Icecast/SHOUTcast streams that // otherwise omit Access-Control-Allow-Origin. Without this, the master's // spectrum visualiser sees only zeros even though the audio plays fine. // Scoped to media responses (audio/* content-type or resourceType 'media') // so we don't accidentally weaken non-audio security guarantees. function installMediaCorsRewrite(sess) { sess.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => { const headers = details.responseHeaders || {}; const ct = (headers['content-type'] || headers['Content-Type'] || [''])[0] || ''; const isMedia = details.resourceType === 'media' || /^audio\/|^application\/(ogg|x-mpegurl|vnd\.apple\.mpegurl)|^video\/mp2t/i.test(ct); if (!isMedia) { callback({ responseHeaders: headers }); return; } // Strip any existing variants so case differences don't leave two copies. for (const k of Object.keys(headers)) { if (/^access-control-allow-origin$/i.test(k)) delete headers[k]; if (/^access-control-allow-headers$/i.test(k)) delete headers[k]; if (/^timing-allow-origin$/i.test(k)) delete headers[k]; } headers['Access-Control-Allow-Origin'] = ['*']; headers['Access-Control-Allow-Headers'] = ['*']; headers['Timing-Allow-Origin'] = ['*']; callback({ responseHeaders: headers }); }); // Auto-grant the `media` permission for our own origin so the preload's // one-shot getUserMedia (used to unlock audio-output device labels) does // not prompt the user. sess.setPermissionRequestHandler((webContents, permission, callback) => { if (permission === 'media') return callback(true); return callback(false); }); } async function createMainWindow(port) { mainWindow = new BrowserWindow({ width: 1280, height: 800, autoHideMenuBar: true, backgroundColor: '#0b0b0c', webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, preload: resolve(__dirname, 'preload.cjs') } }); Menu.setApplicationMenu(null); installMediaCorsRewrite(mainWindow.webContents.session); // Open target/external links in the OS browser instead of inside Electron. mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); // Toggle DevTools (detached/popout) with F12 or Ctrl+Shift+I. const toggleDevTools = () => { const wc = mainWindow.webContents; if (wc.isDevToolsOpened()) wc.closeDevTools(); else wc.openDevTools({ mode: 'detach' }); }; mainWindow.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; const k = (input.key || '').toLowerCase(); if (k === 'f12' || (input.control && input.shift && k === 'i')) { event.preventDefault(); toggleDevTools(); } if (k === 'f5' || (input.control && k === 'r')) { event.preventDefault(); mainWindow.webContents.reloadIgnoringCache(); } }); // Inject two floating buttons (top-right): reload + DevTools toggle. // The sandboxed renderer signals the main process via console markers. mainWindow.webContents.on('console-message', (_e, _level, message) => { if (message === '__oradio_open_devtools__') toggleDevTools(); else if (message === '__oradio_reload__') mainWindow.webContents.reloadIgnoringCache(); }); mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.executeJavaScript(` (() => { window.__oradioOpenDevTools = () => console.log('__oradio_open_devtools__'); window.__oradioReload = () => console.log('__oradio_reload__'); if (document.getElementById('__oradio_devbar')) return; const bar = document.createElement('div'); bar.id = '__oradio_devbar'; Object.assign(bar.style, { position: 'fixed', top: '6px', right: '6px', zIndex: '2147483647', display: 'flex', gap: '4px' }); const mk = (label, title, onclick) => { const b = document.createElement('button'); b.type = 'button'; b.title = title; b.textContent = label; Object.assign(b.style, { width: '32px', height: '32px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.25)', background: 'rgba(0,0,0,0.55)', color: '#fff', font: '16px/1 system-ui, sans-serif', cursor: 'pointer', opacity: '0.4' }); b.addEventListener('mouseenter', () => b.style.opacity = '1'); b.addEventListener('mouseleave', () => b.style.opacity = '0.4'); b.addEventListener('click', onclick); return b; }; bar.appendChild(mk('\u21BB', 'Reload (F5)', () => window.__oradioReload())); bar.appendChild(mk('\u2699', 'DevTools (F12)', () => window.__oradioOpenDevTools())); document.body.appendChild(bar); })(); `).catch(() => { }); }); const startUrl = process.env.ORADIO_DEV_URL || `http://localhost:${port}/master`; await mainWindow.loadURL(startUrl); } app.whenReady().then(async () => { try { const { port } = await bootServer(); await createMainWindow(port); app.on('activate', async () => { if (BrowserWindow.getAllWindows().length === 0) { await createMainWindow(port); } }); } catch (err) { console.error('[electron] failed to start:', err); app.quit(); } }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); let quitting = false; app.on('before-quit', async (event) => { if (quitting || !serverHandle) return; quitting = true; event.preventDefault(); try { await serverHandle.stop(); } catch (e) { console.error(e); } app.quit(); });