From d1513a58b3c08e5096a969da074ea9b6a9a9754b Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 12 Mar 2026 06:56:47 +0100 Subject: [PATCH] feat(remote): wire up remote server, capture window, and IPC handlers in main process Co-Authored-By: Claude Sonnet 4.6 --- main.js | 217 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/main.js b/main.js index 64d9d0b..197f5ef 100644 --- a/main.js +++ b/main.js @@ -11,6 +11,7 @@ const DoodstreamUploader = require('./lib/doodstream-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); const FolderMonitor = require('./lib/folder-monitor'); +const RemoteServer = require('./lib/remote-server'); let mainWindow; let dropTargetWindow = null; @@ -18,6 +19,8 @@ let tray = null; const configStore = new ConfigStore(app); let uploadManager = null; let folderMonitor = new FolderMonitor(); +let remoteServer = null; +let captureWindow = null; const HEALTH_CHECK_TIMEOUT = 25000; // --- Debug logging (writes to upload-debug.log next to the app) --- @@ -488,6 +491,18 @@ app.whenReady().then(() => { debugLog(`folder-monitor auto-start failed: ${err.message}`); } + // Auto-start remote server if enabled + try { + const remoteConfig = configStore.load().globalSettings && configStore.load().globalSettings.remote; + if (remoteConfig && remoteConfig.enabled) { + startRemoteServer().catch(err => { + debugLog(`remote-server auto-start failed: ${err.message}`); + }); + } + } catch (err) { + debugLog(`remote-server auto-start failed: ${err.message}`); + } + // Auto-show drop target if enabled try { const dtConfig = configStore.load(); @@ -517,6 +532,10 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { try { folderMonitor.stop(); } catch {} + try { + if (remoteServer) { remoteServer.stop(); remoteServer = null; } + destroyCaptureWindow(); + } catch {} destroyDropTargetWindow(); }); @@ -891,6 +910,204 @@ ipcMain.handle('folder-monitor:select-folder', async () => { return result.filePaths[0]; }); +// --- Remote Control --- +function generateToken() { + const crypto = require('crypto'); + return crypto.randomBytes(32).toString('hex'); +} + +function createCaptureWindow() { + if (captureWindow && !captureWindow.isDestroyed()) return; + captureWindow = new BrowserWindow({ + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: path.join(__dirname, 'lib', 'remote-capture-preload.js') + } + }); + captureWindow.loadFile(path.join(__dirname, 'lib', 'remote-capture.html')); + + // Crash recovery: if hidden window closes unexpectedly while clients connected, recreate it + captureWindow.on('closed', () => { + captureWindow = null; + if (remoteServer && remoteServer.getClientCount() > 0) { + debugLog('remote: capture window crashed, recreating...'); + createCaptureWindow(); + } + }); +} + +function destroyCaptureWindow() { + if (captureWindow && !captureWindow.isDestroyed()) { + captureWindow.close(); + captureWindow = null; + } +} + +async function startRemoteServer() { + if (remoteServer) { + remoteServer.stop(); + remoteServer = null; + } + + const config = configStore.load(); + const remote = config.globalSettings && config.globalSettings.remote; + if (!remote || !remote.enabled) return; + + let token = remote.token; + if (!token) { + token = generateToken(); + const gs = { ...config.globalSettings, remote: { ...remote, token } }; + await configStore.save({ globalSettings: gs }); + } + + remoteServer = new RemoteServer(); + await remoteServer.start({ + port: remote.port || 9100, + token, + allowInput: remote.allowInput !== false, + mainWindow, + onSignalingToCapture: (data) => { + if (captureWindow && !captureWindow.isDestroyed()) { + captureWindow.webContents.send('remote:signaling-to-capture', data); + } + }, + onCreateCaptureWindow: () => createCaptureWindow(), + onDestroyCaptureWindow: () => destroyCaptureWindow() + }); + + debugLog(`remote-server started on port ${remoteServer.getPort()}`); +} + +// IPC: Signaling from capture window back to dashboard client +ipcMain.on('remote:signaling-from-capture', (_event, data) => { + if (remoteServer && data.clientId) { + remoteServer.sendToClient(data.clientId, data); + } +}); + +// IPC: Input events from capture window +ipcMain.on('remote:input-event', (_event, data) => { + if (!mainWindow || mainWindow.isDestroyed()) return; + + const config = configStore.load(); + const remote = config.globalSettings && config.globalSettings.remote; + if (!remote || !remote.allowInput) return; + if (data.role !== 'admin') return; + + const bounds = mainWindow.getContentBounds(); + const x = Math.round((data.x || 0) * bounds.width); + const y = Math.round((data.y || 0) * bounds.height); + + switch (data.type) { + case 'mousemove': + mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x, y }); + break; + case 'mousedown': + mainWindow.webContents.sendInputEvent({ + type: 'mouseDown', x, y, + button: data.button === 'right' ? 'right' : 'left', + clickCount: 1 + }); + break; + case 'mouseup': + mainWindow.webContents.sendInputEvent({ + type: 'mouseUp', x, y, + button: data.button === 'right' ? 'right' : 'left', + clickCount: 1 + }); + break; + case 'scroll': + mainWindow.webContents.sendInputEvent({ + type: 'mouseWheel', x, y, + deltaX: data.deltaX || 0, + deltaY: data.deltaY || 0 + }); + break; + case 'keydown': + mainWindow.webContents.sendInputEvent({ + type: 'keyDown', + keyCode: data.key, + modifiers: buildModifiers(data) + }); + if (data.key.length === 1) { + mainWindow.webContents.sendInputEvent({ + type: 'char', + keyCode: data.key, + modifiers: buildModifiers(data) + }); + } + break; + case 'keyup': + mainWindow.webContents.sendInputEvent({ + type: 'keyUp', + keyCode: data.key, + modifiers: buildModifiers(data) + }); + break; + } +}); + +function buildModifiers(data) { + const mods = []; + if (data.shift) mods.push('shift'); + if (data.ctrl) mods.push('control'); + if (data.alt) mods.push('alt'); + return mods; +} + +// IPC: Get capture source ID (desktopCapturer must run in main process in Electron 33+) +ipcMain.handle('remote:get-capture-source-id', async () => { + if (!mainWindow || mainWindow.isDestroyed()) return null; + const { desktopCapturer } = require('electron'); + const sources = await desktopCapturer.getSources({ types: ['window'] }); + const title = mainWindow.getTitle(); + const source = sources.find(s => s.name === title); + return source ? source.id : null; +}); + +// IPC: Client count updates from capture window +ipcMain.on('remote:client-count', (_event, count) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('remote:client-count', count); + } +}); + +// IPC: Remote settings +ipcMain.handle('remote:get-settings', () => { + const config = configStore.load(); + return config.globalSettings && config.globalSettings.remote || {}; +}); + +ipcMain.handle('remote:save-settings', async (_event, remoteSettings) => { + const config = configStore.load(); + const gs = { ...config.globalSettings, remote: remoteSettings }; + await configStore.save({ globalSettings: gs }); + + if (remoteSettings.enabled) { + await startRemoteServer(); + } else if (remoteServer) { + remoteServer.stop(); + remoteServer = null; + destroyCaptureWindow(); + debugLog('remote-server stopped'); + } + return true; +}); + +ipcMain.handle('remote:generate-token', () => { + return generateToken(); +}); + +ipcMain.handle('remote:status', () => { + return { + running: !!remoteServer, + port: remoteServer ? remoteServer.getPort() : null, + clientCount: remoteServer ? remoteServer.getClientCount() : 0 + }; +}); + // --- Always on top --- ipcMain.handle('set-always-on-top', async (_event, value) => { if (mainWindow && !mainWindow.isDestroyed()) {