feat(electron-backend): add lightweight HTTP server for remote UI

Add a new HttpServer class that serves the built remote-control-web
static assets and provides a simple REST/API routing layer for remote
control endpoints.

Key changes:
- Implement server lifecycle: start, stop, updateSettings with port and
  enabled handling.
- Resolve distribution path depending on electron packaging (dev vs
  production).
- Serve static files with basic security (normalize path to prevent
  traversal) and SPA fallback to index.html for client-side routing.
- Provide registration API for remote control handlers mapped to
  /api/remote-control/* endpoints.
- Log server status and listening URL on start, and on stop.

These changes enable the Electron backend to host the remote control
web app and route API calls without external web servers, simplifying
development and packaging.
This commit is contained in:
4gray committed 2025-11-22 11:26:22 +01:00
1 parent 5fa4423400
commit a48f7d60be
6 files changed
+336 -1

No files matched your search

@@ -1,6 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electron', {
// Remote control channel change listener
onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => {
ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data));
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
fetchPlaylistByUrl: (url: string, title?: string) =>
@@ -0,0 +1,107 @@
import { BrowserWindow, ipcMain } from 'electron';
import * as http from 'http';
import { httpServer } from '../server/http-server';
import { store } from '../services/store.service';
class RemoteControlEvents {
/**
* Bootstrap remote control events
*/
bootstrapRemoteControlEvents(): void {
// Register HTTP API endpoints
httpServer.registerRemoteControlHandler(
'/api/remote-control/channel/up',
this.handleChannelUp.bind(this)
);
httpServer.registerRemoteControlHandler(
'/api/remote-control/channel/down',
this.handleChannelDown.bind(this)
);
// Start HTTP server if remote control is enabled in settings
const remoteControlEnabled = store.get('remoteControl', false);
const remoteControlPort = store.get('remoteControlPort', 8765);
if (remoteControlEnabled) {
httpServer.start(remoteControlPort);
}
// Register IPC handlers (for when the main app wants to use remote control)
ipcMain.handle('REMOTE_CONTROL_CHANNEL_UP', () => {
return this.changeChannelUp();
});
ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => {
return this.changeChannelDown();
});
}
/**
* HTTP handler for channel up
*/
private handleChannelUp(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.changeChannelUp();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
/**
* HTTP handler for channel down
*/
private handleChannelDown(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
this.changeChannelDown();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
/**
* Change to the next channel
*/
private changeChannelUp(): void {
console.log('Channel UP requested');
this.sendChannelChangeToRenderer('up');
}
/**
* Change to the previous channel
*/
private changeChannelDown(): void {
console.log('Channel DOWN requested');
this.sendChannelChangeToRenderer('down');
}
/**
* Send channel change message to the renderer process
*/
private sendChannelChangeToRenderer(direction: 'up' | 'down'): void {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('CHANNEL_CHANGE', { direction });
console.log(`Sent CHANNEL_CHANGE ${direction} to renderer`);
} else {
console.warn('No browser windows found to send channel change');
}
}
}
export default new RemoteControlEvents();
@@ -3,6 +3,7 @@ import {
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import { httpServer } from '../server/http-server';
export default class SettingsEvents {
static bootstrapSettingsEvents(): Electron.IpcMain {
@@ -12,9 +13,26 @@ export default class SettingsEvents {
ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
console.log('Received SETTINGS_UPDATE with data:', arg);
// Only set values that are defined
if (arg.mpvReuseInstance !== undefined) {
store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance);
}
// Handle remote control settings
if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) {
const enabled = arg.remoteControl ?? store.get('remoteControl', false);
const port = arg.remoteControlPort ?? store.get('remoteControlPort', 8765);
// Save to store
if (arg.remoteControl !== undefined) {
store.set('remoteControl', enabled);
}
if (arg.remoteControlPort !== undefined) {
store.set('remoteControlPort', port);
}
// Update HTTP server
httpServer.updateSettings(enabled, port);
}
});
@@ -0,0 +1,200 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
/**
* HTTP server for serving the remote control web app and providing REST API endpoints
*/
export class HttpServer {
private server: http.Server | null = null;
private port = 8765;
private isEnabled = false;
private distPath: string;
private remoteControlHandlers: Map<
string,
(req: http.IncomingMessage, res: http.ServerResponse) => void
> = new Map();
constructor() {
// Path to the built remote-control-web app
// In development: use workspace root
// In production: use app path
const appPath = app.getAppPath();
const isDev = !app.isPackaged;
if (isDev) {
// Development mode - use workspace root
this.distPath = path.join(
process.cwd(),
'dist',
'apps',
'remote-control-web',
'browser'
);
} else {
// Production mode - files are bundled with the app
this.distPath = path.join(
appPath,
'dist',
'apps',
'remote-control-web',
'browser'
);
}
console.log('[HTTP Server] Serving from:', this.distPath);
}
/**
* Start the HTTP server
*/
start(port?: number): void {
if (port) {
this.port = port;
}
if (this.server) {
console.log('HTTP server is already running');
return;
}
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.listen(this.port, () => {
console.log(`HTTP server listening on port ${this.port}`);
console.log(
`Remote control available at: http://localhost:${this.port}`
);
});
this.isEnabled = true;
}
/**
* Stop the HTTP server
*/
stop(): void {
if (!this.server) {
return;
}
this.server.close(() => {
console.log('HTTP server stopped');
});
this.server = null;
this.isEnabled = false;
}
/**
* Update server settings
*/
updateSettings(enabled: boolean, port: number): void {
const needsRestart = this.isEnabled && enabled && this.port !== port;
if (!enabled && this.isEnabled) {
this.stop();
} else if (enabled && !this.isEnabled) {
this.start(port);
} else if (needsRestart) {
this.stop();
this.start(port);
}
}
/**
* Register a handler for remote control API endpoints
*/
registerRemoteControlHandler(
path: string,
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
): void {
this.remoteControlHandlers.set(path, handler);
}
/**
* Handle incoming HTTP requests
*/
private handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
const url = req.url || '/';
// Handle API requests
if (url.startsWith('/api/remote-control/')) {
const handler = this.remoteControlHandlers.get(url);
if (handler) {
handler(req, res);
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Endpoint not found' }));
return;
}
// Serve static files from the remote-control-web app
this.serveStaticFile(url, res);
}
/**
* Serve static files
*/
private serveStaticFile(url: string, res: http.ServerResponse): void {
// Default to index.html for root path
let filePath = url === '/' ? '/index.html' : url;
// Security: prevent directory traversal
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '');
const fullPath = path.join(this.distPath, filePath);
fs.readFile(fullPath, (err, data) => {
if (err) {
// If file not found, try serving index.html (for Angular routing)
if (err.code === 'ENOENT' && filePath !== '/index.html') {
this.serveStaticFile('/', res);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
return;
}
// Determine content type
const contentType = this.getContentType(fullPath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
}
/**
* Get content type based on file extension
*/
private getContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const contentTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
return contentTypes[ext] || 'application/octet-stream';
}
}
export const httpServer = new HttpServer();
+2
View File
@@ -7,6 +7,7 @@ import ElectronEvents from './app/events/electron.events';
import EpgEvents from './app/events/epg.events';
import PlayerEvents from './app/events/player.events';
import PlaylistEvents from './app/events/playlist.events';
import RemoteControlEvents from './app/events/remote-control.events';
import SettingsEvents from './app/events/settings.events';
import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
@@ -40,6 +41,7 @@ export default class Main {
XtreamEvents.bootstrapXtreamEvents();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// initialize auto updater service
if (!App.isDevelopmentMode()) {
Vendored
+4
View File
@@ -156,6 +156,10 @@ declare global {
xtreamId: number,
playlistId: string
) => Promise<any | null>;
// Remote control
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => void;
};
process: NodeJS.Process;
require: NodeRequire;