feat: add multi-user support for favorites management and room clock synchronization

- 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.
This commit is contained in:
Marco Mooren
2026-05-13 13:53:12 +02:00
parent f6cdfd975c
commit 29423288ca
41 changed files with 4229 additions and 275 deletions

View File

@@ -3,7 +3,7 @@
// source; controllers/panels on other devices keep working over the LAN
// because the server still binds 0.0.0.0.
import { app, BrowserWindow, Menu, shell } from 'electron';
import { app, BrowserWindow, Menu, session, shell } from 'electron';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { mkdirSync } from 'node:fs';
@@ -43,6 +43,43 @@ async function bootServer() {
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,
@@ -52,11 +89,14 @@ async function createMainWindow(port) {
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true
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);

107
electron/preload.cjs Normal file
View File

@@ -0,0 +1,107 @@
// Preload for the Master Electron window.
//
// Exposes `window.oradioNative` to the renderer via `contextBridge`. This is
// the single source of truth for "are we running inside Electron?" — the
// master UI uses `oradioNative?.isElectron` to gate features that don't work
// in a plain browser tab (real audio-output enumeration/selection, the live
// spectrum visualizer's cross-origin PCM tap, etc.).
//
// Runs in a sandboxed isolated world (sandbox: true, contextIsolation: true),
// so it has access to a limited subset of Electron APIs and to the renderer's
// `navigator.mediaDevices`. No Node built-ins.
//
// Written in CommonJS (`.cjs`) on purpose: the repo's package.json sets
// `"type": "module"`, and Electron's preload loader expects CJS.
const { contextBridge } = require('electron');
// ---- Audio output enumeration / selection ------------------------------
// Classify a device label into a coarse `kind` so the master UI can show an
// appropriate icon. Falls back to 'speakers' for the system default.
function classifyKind(label) {
const s = String(label || '').toLowerCase();
if (/bluetooth|\bbt\b|airpods|buds|stanmore|bose|sonos/.test(s)) return 'bluetooth';
if (/hdmi|display|tv|monitor/.test(s)) return 'hdmi';
if (/usb|audient|focusrite|scarlett|presonus|motu/.test(s)) return 'usb';
if (/headphone|headset|earphone|jack/.test(s)) return 'headphones';
return 'speakers';
}
let labelsUnlocked = false;
const currentListeners = new Set();
let lastCurrentId = 'default';
async function unlockDeviceLabels() {
if (labelsUnlocked) return;
// Asking for an audio input stream gives Chromium permission to expose
// device LABELS in subsequent enumerateDevices() calls. The Electron main
// process auto-grants the `media` permission so this is silent.
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
for (const t of stream.getTracks()) t.stop();
labelsUnlocked = true;
} catch (err) {
// Permission denied or no input device — labels will be empty but we
// can still surface the IDs (and the system default).
console.warn('[preload] could not unlock device labels:', err?.message || err);
}
}
async function listOutputs() {
if (!navigator.mediaDevices?.enumerateDevices) return [];
await unlockDeviceLabels();
const all = await navigator.mediaDevices.enumerateDevices();
const out = all
.filter((d) => d.kind === 'audiooutput')
.map((d, i) => ({
id: d.deviceId,
label: d.label || (d.deviceId === 'default' ? 'System default' : `Audio output ${i + 1}`),
kind: classifyKind(d.label)
}));
// Ensure 'default' is always first when present.
out.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : 0));
return out;
}
async function setOutput(id) {
lastCurrentId = String(id || 'default');
// The actual `audio.setSinkId(id)` call has to happen on the renderer's
// <audio> element, which lives in the master's main-world context. The
// master code calls setSinkId directly after this resolves — preload only
// tracks the selection and notifies listeners.
for (const cb of currentListeners) {
try { cb(lastCurrentId); } catch { /* ignore listener errors */ }
}
return lastCurrentId;
}
function getCurrent() {
return lastCurrentId;
}
function onCurrentChanged(cb) {
if (typeof cb !== 'function') return () => { };
currentListeners.add(cb);
return () => currentListeners.delete(cb);
}
// Re-notify when the OS device set changes (cable plug/unplug, Bluetooth
// connect/disconnect). The renderer can call listOutputs() again on this.
if (navigator.mediaDevices?.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', () => {
for (const cb of currentListeners) {
try { cb(lastCurrentId); } catch { /* ignore */ }
}
});
}
// ---- Bridge ------------------------------------------------------------
contextBridge.exposeInMainWorld('oradioNative', {
isElectron: true,
listOutputs,
setOutput,
getCurrent,
onCurrentChanged
});