feat: remote control agent — WebSocket client + Remote settings tab

- electron/remote.js: WebSocket client that connects to central server
  - Smart URL builder: domain+443→wss://, IP/non-443→ws://, http/https→ws/wss
  - UUID token auth on connect
  - Auto-reconnect with exponential backoff (3s→60s)
  - Handles commands: start_stream, stop_stream, update_stream, stop_all
  - Sends: devices, stream_status, logs
  - Ping/pong keepalive every 25s
  - Self-signed cert allowed for local IPs (192.168.x, 10.x, .local)
- electron/main.js: IPC handlers remote-connect/disconnect/get-url,
  generate-token; forwards commands to FFmpeg, status to renderer
- electron/preload.js: exposes remoteConnect, remoteDisconnect,
  remoteGetUrl, generateToken, onRemoteStatus, onRemoteCommand
- src/components/RemoteSettings.jsx: new UI tab
  - Server + port fields (default 443, auto wss/ws)
  - URL preview
  - Token display with copy + regenerate
  - Machine name field
  - Connection status bar with animated states
- src/App.jsx: Remote tab added, remote state persisted to config
- src/styles/App.css: remote status bar, token display, actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
admin
2026-04-23 19:02:57 +03:00
parent e9a9e336da
commit 96b099d892
7 changed files with 667 additions and 5 deletions
+79 -3
View File
@@ -1,8 +1,9 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog, shell } = require('electron')
const path = require('path')
const fs = require('fs')
const ffmpegManager = require('./ffmpeg')
const deviceManager = require('./devices')
const ffmpegManager = require('./ffmpeg')
const deviceManager = require('./devices')
const remoteClient = require('./remote')
// ─── Icon helpers ──────────────────────────────────────────────────────────────
// Draw a pixel into an RGBA buffer
@@ -124,7 +125,12 @@ function loadConfig() {
} catch (e) {
console.error('Failed to load config:', e)
}
return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 }, recording: { folder: '', segmentMinutes: 60 } }
return {
streams: [],
tolbek: { enabled: false, port: 5000, latency: 200 },
recording: { folder: '', segmentMinutes: 60 },
remote: { enabled: false, server: '', port: '', token: require('./remote').RemoteClient.generateToken(), machineName: '' }
}
}
function saveConfig(config) {
@@ -336,6 +342,29 @@ ipcMain.handle('open-folder', async (_, folderPath) => {
try { await shell.openPath(folderPath) } catch {}
})
// ─── Remote control IPC ───────────────────────────────────────────────────────
ipcMain.handle('remote-connect', async (_, { server, port, token, machineName }) => {
try {
remoteClient.connect({ server, port, token, machineName })
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
ipcMain.handle('remote-disconnect', () => {
remoteClient.disconnect()
return { success: true }
})
ipcMain.handle('remote-get-url', (_, { server, port }) => {
return require('./remote').buildWsUrl(server, port)
})
ipcMain.handle('generate-token', () => {
return require('./remote').RemoteClient.generateToken()
})
ipcMain.handle('minimize-window', () => {
mainWindow && mainWindow.minimize()
})
@@ -367,6 +396,53 @@ ffmpegManager.on('reconnecting', (data) => {
mainWindow && mainWindow.webContents.send('stream-reconnecting', data)
})
// ─── Remote client events ─────────────────────────────────────────────────────
remoteClient.on('status', (data) => {
mainWindow && mainWindow.webContents.send('remote-status', data)
})
// Server requested device list → fetch and send back
remoteClient.on('request_devices', async () => {
try {
const devices = await deviceManager.getDevices()
remoteClient.sendDevices(devices)
} catch {}
})
// Server sent a command → execute locally
remoteClient.on('command', async ({ action, config, id, changes }) => {
try {
if (action === 'start_stream') {
// Update UI first
mainWindow && mainWindow.webContents.send('remote-command', { action: 'start_stream', config })
const result = await ffmpegManager.startStream(config)
updateTrayMenu(ffmpegManager.getActiveCount())
remoteClient.sendStreamStatus(config.id, 'running')
} else if (action === 'stop_stream') {
ffmpegManager.stopStream(id)
updateTrayMenu(ffmpegManager.getActiveCount())
remoteClient.sendStreamStatus(id, 'idle')
mainWindow && mainWindow.webContents.send('remote-command', { action: 'stop_stream', id })
} else if (action === 'update_stream') {
mainWindow && mainWindow.webContents.send('remote-command', { action: 'update_stream', id, changes })
} else if (action === 'stop_all') {
ffmpegManager.stopAllStreams()
updateTrayMenu(0)
mainWindow && mainWindow.webContents.send('all-streams-stopped')
}
} catch (e) {
console.error('[Remote] Command error:', e)
}
})
// Forward stream events to remote server
ffmpegManager.on('ended', (data) => {
remoteClient.sendStreamStatus(data.id, 'idle')
})
ffmpegManager.on('error', (data) => {
remoteClient.sendStreamStatus(data.id, 'error')
})
app.whenReady().then(() => {
createWindow()
createTray()
+8
View File
@@ -22,6 +22,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
pickFolder: () => ipcRenderer.invoke('pick-folder'),
openFolder: (p) => ipcRenderer.invoke('open-folder', p),
// Remote control
remoteConnect: (cfg) => ipcRenderer.invoke('remote-connect', cfg),
remoteDisconnect: () => ipcRenderer.invoke('remote-disconnect'),
remoteGetUrl: (cfg) => ipcRenderer.invoke('remote-get-url', cfg),
generateToken: () => ipcRenderer.invoke('generate-token'),
// Window controls
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
hideWindow: () => ipcRenderer.invoke('hide-window'),
@@ -34,6 +40,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, d) => cb(d)),
onStreamReconnecting: (cb) => ipcRenderer.on('stream-reconnecting', (_, d) => cb(d)),
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
onRemoteStatus: (cb) => ipcRenderer.on('remote-status', (_, d) => cb(d)),
onRemoteCommand: (cb) => ipcRenderer.on('remote-command', (_, d) => cb(d)),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
})
+234
View File
@@ -0,0 +1,234 @@
'use strict'
const { EventEmitter } = require('events')
const { randomUUID } = require('crypto')
const WebSocket = require('ws')
// ─── URL builder ──────────────────────────────────────────────────────────────
// Accepts any of:
// srt.queo.ru → wss://srt.queo.ru:443
// srt.queo.ru:8080 → ws://srt.queo.ru:8080 (non-443 = assume no SSL)
// 192.168.1.5 → ws://192.168.1.5:443
// 192.168.1.5:3000 → ws://192.168.1.5:3000
// https://srt.queo.ru → wss://srt.queo.ru:443
// http://pi.local:3000 → ws://pi.local:3000
function buildWsUrl(server, port) {
let s = (server || '').trim()
// Already has explicit ws:// or wss:// — use as-is (port ignored)
if (/^wss?:\/\//i.test(s)) return s
// Strip http(s):// prefix, remember the scheme
let forceSecure = null
if (/^https:\/\//i.test(s)) { s = s.replace(/^https:\/\//i, ''); forceSecure = true }
else if (/^http:\/\//i.test(s)) { s = s.replace(/^http:\/\//i, ''); forceSecure = false }
// Parse host and optional inline port
let host = s
let resolvedPort = parseInt(port) || 443
const colonIdx = s.lastIndexOf(':')
if (colonIdx !== -1) {
const maybePort = parseInt(s.slice(colonIdx + 1))
if (maybePort > 0 && maybePort < 65536) {
host = s.slice(0, colonIdx)
resolvedPort = maybePort
}
}
// Choose protocol: wss for port 443 or explicit https, ws otherwise
const secure = forceSecure !== null ? forceSecure : resolvedPort === 443
const proto = secure ? 'wss' : 'ws'
return `${proto}://${host}:${resolvedPort}`
}
// ─── RemoteClient ─────────────────────────────────────────────────────────────
class RemoteClient extends EventEmitter {
constructor() {
super()
this.ws = null
this.connected = false
this.connecting = false
this._manualClose = false
this._retryTimer = null
this._retryCount = 0
this._pingInterval = null
this.serverUrl = null
this.token = null
this.machineName = null
}
// ── Connect ────────────────────────────────────────────────────────────────
connect({ server, port, token, machineName }) {
this._manualClose = false
this.token = token
this.machineName = machineName || require('os').hostname()
this.serverUrl = buildWsUrl(server, port)
console.log('[Remote] Connecting to', this.serverUrl)
this._doConnect()
}
_doConnect() {
if (this.ws) {
try { this.ws.terminate() } catch {}
this.ws = null
}
this.connecting = true
this.emit('status', { status: 'connecting', url: this.serverUrl })
const ws = new WebSocket(this.serverUrl, {
handshakeTimeout: 10000,
// Allow self-signed certs on local network
rejectUnauthorized: !this.serverUrl.includes('192.168.') &&
!this.serverUrl.includes('10.') &&
!this.serverUrl.includes('172.') &&
!this.serverUrl.includes('localhost') &&
!this.serverUrl.includes('.local')
})
this.ws = ws
ws.on('open', () => {
this.connected = false // becomes true after auth_ok
this.connecting = false
this._retryCount = 0
// Send auth immediately
this._send({ type: 'auth', token: this.token, machineName: this.machineName, version: '2.0.0' })
// Keepalive ping every 25s
this._pingInterval = setInterval(() => this._send({ type: 'ping' }), 25000)
})
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw)
this._handleMessage(msg)
} catch (e) {
console.warn('[Remote] Invalid message:', raw)
}
})
ws.on('close', (code, reason) => {
this._cleanup()
if (!this._manualClose) {
const delay = Math.min(3000 * Math.pow(1.5, this._retryCount), 60000)
this._retryCount++
console.log(`[Remote] Disconnected (${code}), retrying in ${(delay/1000).toFixed(1)}s`)
this.emit('status', { status: 'disconnected', retryIn: delay })
this._retryTimer = setTimeout(() => this._doConnect(), delay)
} else {
this.emit('status', { status: 'disconnected' })
}
})
ws.on('error', (err) => {
console.error('[Remote] WS error:', err.message)
this.emit('status', { status: 'error', error: err.message })
// 'close' fires after 'error', so reconnect is handled there
})
}
disconnect() {
this._manualClose = true
this._cleanup()
if (this.ws) {
try { this.ws.terminate() } catch {}
this.ws = null
}
this.emit('status', { status: 'disconnected' })
}
_cleanup() {
this.connected = false
this.connecting = false
clearInterval(this._pingInterval)
clearTimeout(this._retryTimer)
this._pingInterval = null
this._retryTimer = null
}
// ── Incoming messages ──────────────────────────────────────────────────────
_handleMessage(msg) {
switch (msg.type) {
case 'auth_ok':
this.connected = true
console.log('[Remote] Authenticated, server name:', msg.serverName)
this.emit('status', { status: 'connected', serverName: msg.serverName })
// Server may request initial state right away
this.emit('request_devices')
break
case 'auth_fail':
console.error('[Remote] Auth failed:', msg.reason)
this.emit('status', { status: 'auth_failed', reason: msg.reason })
this.disconnect()
break
case 'get_devices':
this.emit('request_devices')
break
case 'start_stream':
this.emit('command', { action: 'start_stream', config: msg.config })
break
case 'stop_stream':
this.emit('command', { action: 'stop_stream', id: msg.id })
break
case 'update_stream':
this.emit('command', { action: 'update_stream', id: msg.id, changes: msg.changes })
break
case 'stop_all':
this.emit('command', { action: 'stop_all' })
break
case 'pong':
break // keepalive response
default:
console.log('[Remote] Unknown message type:', msg.type)
}
}
// ── Outgoing messages ──────────────────────────────────────────────────────
_send(obj) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try { this.ws.send(JSON.stringify(obj)) } catch {}
}
}
sendDevices(devices) {
this._send({ type: 'devices', devices })
}
sendStreamStatus(id, status) {
this._send({ type: 'stream_status', id, status })
}
sendAllStatus(streams) {
this._send({ type: 'all_status', streams })
}
sendLog(id, text) {
// Throttle logs — send only every ~500ms or on errors
this._send({ type: 'log', id, text })
}
// ── Static helper ──────────────────────────────────────────────────────────
static generateToken() {
return randomUUID()
}
static buildWsUrl(server, port) {
return buildWsUrl(server, port)
}
}
module.exports = new RemoteClient()
module.exports.RemoteClient = RemoteClient
module.exports.buildWsUrl = buildWsUrl