96b099d892
- 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>
235 lines
7.7 KiB
JavaScript
235 lines
7.7 KiB
JavaScript
'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
|