mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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();
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user