Files
radio-explorer/electron/main.js
Marco Mooren f6cdfd975c feat: integrate Electron for desktop application support
- Added Electron entry point in `electron/main.js` to run the Express server in-process and open the main application window.
- Updated `package.json` to include Electron dependencies and scripts for building and running the application.
- Refactored server startup logic into `server/start.js` for better modularity and to support both CLI and Electron usage.
- Implemented environment variable handling for database and image paths to accommodate Electron's packaging.
- Created a script `server/scripts/promote-morphix.js` to merge admin and morphix accounts into a single user.
- Adjusted image root path resolution in `server/media/images.js` to allow for environment variable overrides.
- Cleaned up `server/index.js` to delegate server initialization to the new `startServer` function.
2026-05-11 18:55:02 +02:00

159 lines
6.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, 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;
}
async function createMainWindow(port) {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
autoHideMenuBar: true,
backgroundColor: '#0b0b0c',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
Menu.setApplicationMenu(null);
// 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();
});