- Implemented a new API endpoint for retrieving and managing user favorites in /api/users. - Added functionality for admins to edit the shared "main" user's favorites. - Created a one-shot DB smoke test script for verifying multi-user kiosk migrations. - Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
199 lines
8.3 KiB
JavaScript
199 lines
8.3 KiB
JavaScript
// 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';
|
|
|
|
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%/<productName> 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();
|
|
});
|