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.
This commit is contained in:
@@ -1,89 +1,9 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import { createServer } from 'node:http';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
// CLI entry: `node server/index.js` (or `npm start`). For the Electron build,
|
||||
// see `electron/main.js`, which calls `startServer()` directly.
|
||||
|
||||
import { initDb } from './db/index.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
||||
import { applySeedIfEmpty } from './sources/seed.js';
|
||||
import { scheduleHealthCheck } from './streams/checker.js';
|
||||
import { attachWs } from './ws.js';
|
||||
import { ensureImageDirs, getImageRoot } from './media/images.js';
|
||||
import { startServer } from './start.js';
|
||||
|
||||
import { router as authRoutes } from './routes/auth.js';
|
||||
import { router as stationRoutes } from './routes/stations.js';
|
||||
import { router as meRoutes } from './routes/me.js';
|
||||
import { router as adminRoutes } from './routes/admin.js';
|
||||
import { router as v1Routes } from './routes/v1.js';
|
||||
import { router as roomRoutes } from './routes/rooms.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
|
||||
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
ensureBootstrapAdmin({
|
||||
username: process.env.ADMIN_BOOTSTRAP_USER,
|
||||
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
||||
});
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
ensureImageDirs();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '512kb' }));
|
||||
app.use(authMiddleware);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/stations', stationRoutes);
|
||||
app.use('/api/me', meRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/v1', v1Routes);
|
||||
|
||||
// Locally-cached cover art and other media live under data/images and are
|
||||
// served unauthenticated on the LAN. Long cache OK — file names include the
|
||||
// station id and we rewrite the file on update (browsers also revalidate).
|
||||
app.use('/media', express.static(getImageRoot(), {
|
||||
maxAge: '1h',
|
||||
fallthrough: false,
|
||||
setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); }
|
||||
}));
|
||||
|
||||
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
|
||||
// HTML entry files must NOT be cached — they reference hashed JS/CSS that changes
|
||||
// every build. Hashed assets under /assets/ can be cached aggressively.
|
||||
const publicDir = resolve(__dirname, 'public');
|
||||
const sendHtml = (file) => (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(resolve(publicDir, file));
|
||||
};
|
||||
if (existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir, {
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
} else if (filePath.includes(`${publicDir}\\assets\\`) || filePath.includes(`${publicDir}/assets/`)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.get('/admin', sendHtml('admin/index.html'));
|
||||
app.get('/docs', sendHtml('docs/index.html'));
|
||||
app.get('/master', sendHtml('master/index.html'));
|
||||
app.get('*', sendHtml('index.html'));
|
||||
}
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: String(err.message || err) });
|
||||
});
|
||||
|
||||
const server = createServer(app);
|
||||
attachWs(server);
|
||||
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[oradio] api+ws on http://localhost:${PORT}`);
|
||||
startServer().catch((err) => {
|
||||
console.error('[oradio] failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..', 'data', 'images');
|
||||
// Where cover art lives. Overridable via env so Electron (packaged) can point
|
||||
// it at app.getPath('userData')/data/images instead of the read-only asar dir.
|
||||
const ROOT = process.env.ORADIO_IMAGE_ROOT
|
||||
? resolve(process.env.ORADIO_IMAGE_ROOT)
|
||||
: resolve(__dirname, '..', '..', 'data', 'images');
|
||||
const STATIONS_DIR = join(ROOT, 'stations');
|
||||
// A browser-like UA is required by Wikimedia and several CDNs; an opaque
|
||||
// UA like "OnlineRadioExplorer/0.1" gets HTTP 400/403 from upload.wikimedia.org.
|
||||
|
||||
63
server/scripts/promote-morphix.js
Normal file
63
server/scripts/promote-morphix.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// One-off: collapse admin + morphix into a single account named "morphix".
|
||||
// Run with: npx electron server/scripts/promote-morphix.js
|
||||
//
|
||||
// Strategy: if both users exist, delete morphix (which has no favorites/history),
|
||||
// then rename admin -> morphix and reset its password. If only one exists,
|
||||
// just rename/ensure morphix with the new password.
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { resolve } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
|
||||
const NEW_NAME = 'morphix';
|
||||
const NEW_PASSWORD = '234Tgb999!';
|
||||
|
||||
const dbPath = process.env.DB_PATH || resolve(process.cwd(), 'data', 'db', 'oradio.sqlite');
|
||||
console.log('[promote] db:', dbPath);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const admin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin');
|
||||
const morphix = db.prepare('SELECT id FROM users WHERE username = ?').get('morphix');
|
||||
const hash = bcrypt.hashSync(NEW_PASSWORD, 10);
|
||||
|
||||
db.transaction(() => {
|
||||
if (admin && morphix) {
|
||||
// Drop the empty morphix so the username is free, then rename admin.
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(morphix.id);
|
||||
db.prepare('UPDATE users SET username = ?, password_hash = ?, role = ? WHERE id = ?')
|
||||
.run(NEW_NAME, hash, 'admin', admin.id);
|
||||
// Ensure profile display name is sane.
|
||||
db.prepare(`INSERT INTO profiles (user_id, display_name) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`)
|
||||
.run(admin.id, NEW_NAME);
|
||||
console.log(`[promote] merged: morphix (id ${morphix.id}) deleted, admin (id ${admin.id}) renamed to morphix`);
|
||||
} else if (admin && !morphix) {
|
||||
db.prepare('UPDATE users SET username = ?, password_hash = ?, role = ? WHERE id = ?')
|
||||
.run(NEW_NAME, hash, 'admin', admin.id);
|
||||
db.prepare(`INSERT INTO profiles (user_id, display_name) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`)
|
||||
.run(admin.id, NEW_NAME);
|
||||
console.log(`[promote] renamed admin -> morphix (id ${admin.id})`);
|
||||
} else if (!admin && morphix) {
|
||||
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE id = ?')
|
||||
.run(hash, 'admin', morphix.id);
|
||||
console.log(`[promote] morphix already exists (id ${morphix.id}); password reset`);
|
||||
} else {
|
||||
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||
.run(NEW_NAME, hash, 'admin');
|
||||
db.prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
|
||||
.run(info.lastInsertRowid, NEW_NAME);
|
||||
console.log(`[promote] created morphix (id ${info.lastInsertRowid})`);
|
||||
}
|
||||
|
||||
// Invalidate any cached sessions so existing logins don't end up tied
|
||||
// to a now-deleted user id.
|
||||
db.prepare('DELETE FROM sessions').run();
|
||||
})();
|
||||
|
||||
db.close();
|
||||
console.log('[promote] done. Sessions cleared — log in again as morphix / ' + NEW_PASSWORD);
|
||||
app.quit();
|
||||
106
server/start.js
Normal file
106
server/start.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// Bootstraps the HTTP API + WebSocket hub + static assets.
|
||||
//
|
||||
// Exposed as a function so the Electron main process can boot the server
|
||||
// in-process (sharing the event loop, no IPC). `server/index.js` calls
|
||||
// this for plain `node server/index.js` CLI use.
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import { createServer } from 'node:http';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { initDb } from './db/index.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
||||
import { applySeedIfEmpty } from './sources/seed.js';
|
||||
import { scheduleHealthCheck } from './streams/checker.js';
|
||||
import { attachWs } from './ws.js';
|
||||
import { ensureImageDirs, getImageRoot } from './media/images.js';
|
||||
|
||||
import { router as authRoutes } from './routes/auth.js';
|
||||
import { router as stationRoutes } from './routes/stations.js';
|
||||
import { router as meRoutes } from './routes/me.js';
|
||||
import { router as adminRoutes } from './routes/admin.js';
|
||||
import { router as v1Routes } from './routes/v1.js';
|
||||
import { router as roomRoutes } from './routes/rooms.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function startServer(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 4173);
|
||||
const host = opts.host ?? process.env.HOST ?? '0.0.0.0';
|
||||
const dbPath = opts.dbPath ?? process.env.DB_PATH ?? './data/db/oradio.sqlite';
|
||||
|
||||
initDb(dbPath);
|
||||
ensureBootstrapAdmin({
|
||||
username: process.env.ADMIN_BOOTSTRAP_USER,
|
||||
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
||||
});
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
ensureImageDirs();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '512kb' }));
|
||||
app.use(authMiddleware);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/stations', stationRoutes);
|
||||
app.use('/api/me', meRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/v1', v1Routes);
|
||||
|
||||
// Locally-cached cover art and other media live under the configured image
|
||||
// root and are served unauthenticated on the LAN. Long cache OK — file names
|
||||
// include the station id and we rewrite the file on update.
|
||||
app.use('/media', express.static(getImageRoot(), {
|
||||
maxAge: '1h',
|
||||
fallthrough: false,
|
||||
setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); }
|
||||
}));
|
||||
|
||||
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
|
||||
const publicDir = opts.publicDir ?? resolve(__dirname, 'public');
|
||||
const sendHtml = (file) => (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(resolve(publicDir, file));
|
||||
};
|
||||
if (existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir, {
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
} else if (filePath.includes(`${publicDir}\\assets\\`) || filePath.includes(`${publicDir}/assets/`)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.get('/admin', sendHtml('admin/index.html'));
|
||||
app.get('/docs', sendHtml('docs/index.html'));
|
||||
app.get('/master', sendHtml('master/index.html'));
|
||||
app.get('*', sendHtml('index.html'));
|
||||
}
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: String(err.message || err) });
|
||||
});
|
||||
|
||||
const server = createServer(app);
|
||||
attachWs(server);
|
||||
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
|
||||
|
||||
await new Promise((resolveListen) => server.listen(port, host, resolveListen));
|
||||
const addr = server.address();
|
||||
const actualPort = (addr && typeof addr === 'object') ? addr.port : port;
|
||||
console.log(`[oradio] api+ws on http://${host}:${actualPort}`);
|
||||
|
||||
return {
|
||||
server,
|
||||
port: actualPort,
|
||||
host,
|
||||
stop: () => new Promise((res) => server.close(() => res()))
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user