Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/electron-backend/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": {
"dependsOn": [
{
"projects": ["web"],
"projects": ["web", "remote-control-web"],
"target": "build"
}
],
Expand Down Expand Up @@ -54,6 +54,7 @@
"nx run electron-backend:serve-electron"
]
}

},
"serve-electron": {
"executor": "nx-electron:execute",
Expand Down
4 changes: 4 additions & 0 deletions apps/electron-backend/src/app/api/main.preload.ts
Original file line number Diff line number Diff line change
@@ -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));
},
Comment on lines +4 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add cleanup mechanism for the IPC listener.

The onChannelChange listener registration doesn't provide a way to unsubscribe, which can lead to memory leaks if multiple listeners are registered or if the component using this API is destroyed and recreated.

Consider returning a cleanup function:

-    onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => {
-        ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data));
-    },
+    onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => {
+        const handler = (_event: any, data: any) => callback(data);
+        ipcRenderer.on('CHANNEL_CHANGE', handler);
+        return () => ipcRenderer.removeListener('CHANNEL_CHANGE', handler);
+    },
🤖 Prompt for AI Agents
In apps/electron-backend/src/app/api/main.preload.ts around lines 4 to 7, the
onChannelChange IPC listener is registered but not removed; change its API to
return an unsubscribe/cleanup function that calls ipcRenderer.removeListener (or
ipcRenderer.off) for the 'CHANNEL_CHANGE' event, and ensure the listener
callback is stored as a named function reference so removeListener can
unregister exactly that handler; update callers to call the returned cleanup
when the component unmounts or no longer needs the listener.

getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
fetchPlaylistByUrl: (url: string, title?: string) =>
Expand Down
107 changes: 107 additions & 0 deletions apps/electron-backend/src/app/events/remote-control.events.ts
Original file line number Diff line number Diff line change
@@ -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();
20 changes: 19 additions & 1 deletion apps/electron-backend/src/app/events/settings.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
});
200 changes: 200 additions & 0 deletions apps/electron-backend/src/app/server/http-server.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions apps/electron-backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +41,7 @@ export default class Main {
XtreamEvents.bootstrapXtreamEvents();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();

// initialize auto updater service
if (!App.isDevelopmentMode()) {
Expand Down
Loading