Files
srt-streamer/electron/main.js
T
admin 96b099d892 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>
2026-04-23 19:02:57 +03:00

470 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 remoteClient = require('./remote')
// ─── Icon helpers ──────────────────────────────────────────────────────────────
// Draw a pixel into an RGBA buffer
function setPixel(buf, x, y, W, r, g, b, a = 255) {
if (x < 0 || y < 0 || x >= W || y >= W) return
const i = (y * W + x) * 4
buf[i] = r; buf[i+1] = g; buf[i+2] = b; buf[i+3] = a
}
// Anti-aliased circle helper: returns alpha 0-255 for a pixel at (px,py)
// relative to circle center (cx,cy) with radius r
function circleAlpha(px, py, cx, cy, r) {
const d = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2)
if (d > r + 0.7) return 0
if (d < r - 0.7) return 255
return Math.round((r + 0.7 - d) / 1.4 * 255)
}
// Generate tray icon as raw RGBA buffer — works on all platforms
// SVG DataURL does NOT work on Windows Electron tray, RGBA buffer does.
function makeTrayIconBuffer(size = 16) {
const buf = Buffer.alloc(size * size * 4, 0)
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// Rounded background
const cx = size / 2 - 0.5, cy = size / 2 - 0.5, r = size / 2 - 0.5
const dx = x - cx, dy = y - cy
if (dx * dx + dy * dy > r * r) continue // outside circle → transparent
// Dark background
setPixel(buf, x, y, size, 20, 20, 38, 255)
// Play triangle: points (3, 2), (3, 13), (12, 7.5)
const tL = 3, tT = 2, tB = size - 2, tR = size - 3
const midY = (tT + tB) / 2
// A point is inside the triangle if it satisfies all three half-plane tests
const cross1 = (tR - tL) * (y - tT) - (midY - tT) * (x - tL) // left-top to right
const cross2 = (tL - tR) * (y - midY) - (tT - midY) * (x - tR) // right to left-bot
const cross3 = (0) * (y - tB) - (tT - tB) * (x - tL) // left edge
// Simplified inside-triangle check
const inTri = x >= tL && x <= tR &&
y >= (tT + (midY - tT) / (tR - tL) * (x - tL) - 0.5) &&
y <= (tB - (tB - midY) / (tR - tL) * (x - tL) + 0.5)
if (inTri) {
setPixel(buf, x, y, size, 79, 142, 247, 255) // accent blue
continue
}
// Red live dot — top right quadrant
const dotCX = size * 0.78, dotCY = size * 0.22, dotR = size * 0.18
const da = circleAlpha(x, y, dotCX, dotCY, dotR)
if (da > 0) {
// Inner white
const innerA = circleAlpha(x, y, dotCX, dotCY, dotR * 0.5)
if (innerA > 128) setPixel(buf, x, y, size, 255, 255, 255, innerA)
else setPixel(buf, x, y, size, 239, 68, 68, da)
}
}
}
return buf
}
function makeTrayIcon() {
// 1. Try to load pre-generated file (faster, better quality)
const pngPaths = [
path.join(__dirname, '../assets/tray-icon.png'),
path.join(process.resourcesPath || '', '../assets/tray-icon.png')
]
for (const p of pngPaths) {
if (fs.existsSync(p)) {
try {
const img = nativeImage.createFromPath(p)
if (!img.isEmpty()) {
if (process.platform === 'darwin') img.setTemplateImage(true)
return img
}
} catch {}
}
}
// 2. Fallback: create from raw RGBA buffer (always works, no file needed)
const size = process.platform === 'darwin' ? 22 : 16
const buf = makeTrayIconBuffer(size)
const img = nativeImage.createFromBuffer(buf, { width: size, height: size })
if (process.platform === 'darwin') img.setTemplateImage(true)
return img
}
function makeWindowIcon() {
// Use pre-generated icon.png if available
const pngPath = path.join(__dirname, '../assets/icon.png')
if (fs.existsSync(pngPath)) {
try {
const img = nativeImage.createFromPath(pngPath)
if (!img.isEmpty()) return img
} catch {}
}
// Fallback: 32×32 RGBA buffer
const size = 32
const buf = makeTrayIconBuffer(size)
return nativeImage.createFromBuffer(buf, { width: size, height: size })
}
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
let mainWindow = null
let tray = null
let isQuitting = false
// Persistent config storage
const configPath = path.join(app.getPath('userData'), 'config.json')
function loadConfig() {
try {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf8'))
}
} catch (e) {
console.error('Failed to load config:', e)
}
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) {
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
} catch (e) {
console.error('Failed to save config:', e)
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1100,
height: 750,
minWidth: 800,
minHeight: 600,
backgroundColor: '#1a1a2e',
frame: false,
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#16213e',
symbolColor: '#e0e0e0',
height: 36
},
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
icon: makeWindowIcon()
})
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
// Hide to tray on close instead of quitting
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow.hide()
if (process.platform === 'darwin') {
app.dock.hide()
}
}
})
}
function createTray() {
const trayImage = makeTrayIcon()
tray = new Tray(trayImage)
tray.setToolTip('SRT Streamer')
updateTrayMenu()
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.focus()
} else {
showWindow()
}
}
})
}
function confirmAndQuit() {
const activeCount = ffmpegManager.getActiveCount()
const detail = activeCount > 0
? `Активных потоков: ${activeCount}. Все будут остановлены.`
: 'Приложение будет закрыто.'
const choice = dialog.showMessageBoxSync(mainWindow || undefined, {
type: 'question',
buttons: ['Выйти', 'Отмена'],
defaultId: 1,
cancelId: 1,
title: 'SRT Streamer — выход',
message: 'Вы уверены, что хотите выйти?',
detail
})
if (choice === 0) {
isQuitting = true
ffmpegManager.stopAllStreams()
app.quit()
}
}
function showWindow() {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
if (process.platform === 'darwin') {
app.dock.show()
}
}
}
function updateTrayMenu(activeCount = 0) {
if (!tray) return
const statusLabel = activeCount > 0
? `▶ Активных потоков: ${activeCount}`
: '⬛ Нет активных потоков'
const contextMenu = Menu.buildFromTemplate([
{ label: 'SRT Streamer', enabled: false },
{ label: statusLabel, enabled: false },
{ type: 'separator' },
{ label: '📂 Открыть', click: () => showWindow() },
{ type: 'separator' },
{
label: '⏹ Остановить все потоки',
enabled: activeCount > 0,
click: () => {
ffmpegManager.stopAllStreams()
mainWindow && mainWindow.webContents.send('all-streams-stopped')
}
},
{ type: 'separator' },
{ label: '✖ Выход', click: () => confirmAndQuit() }
])
tray.setContextMenu(contextMenu)
}
// IPC Handlers
ipcMain.handle('get-config', () => loadConfig())
ipcMain.handle('save-config', (_, config) => { saveConfig(config); return true })
ipcMain.handle('get-devices', async () => {
try {
return await deviceManager.getDevices()
} catch (e) {
console.error('Device enumeration error:', e)
return { video: [], audio: [] }
}
})
ipcMain.handle('get-windows', async () => {
try {
return await deviceManager.getWindows()
} catch (e) {
return []
}
})
ipcMain.handle('start-stream', async (_, streamConfig) => {
try {
const result = await ffmpegManager.startStream(streamConfig)
const active = ffmpegManager.getActiveCount()
updateTrayMenu(active)
mainWindow && mainWindow.webContents.send('stream-status', {
id: streamConfig.id,
status: 'running'
})
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
ipcMain.handle('stop-stream', async (_, streamId) => {
try {
ffmpegManager.stopStream(streamId)
const active = ffmpegManager.getActiveCount()
updateTrayMenu(active)
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
ipcMain.handle('start-tolbek', async (_, config) => {
try {
const result = await ffmpegManager.startTolbek(config)
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
ipcMain.handle('stop-tolbek', async () => {
try {
ffmpegManager.stopTolbek()
return { success: true }
} catch (e) {
return { success: false, error: e.message }
}
})
ipcMain.handle('get-active-streams', () => {
return ffmpegManager.getActiveStreams()
})
ipcMain.handle('pick-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow || undefined, {
title: 'Выберите папку для записи',
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled || !result.filePaths.length) return null
return result.filePaths[0]
})
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()
})
ipcMain.handle('hide-window', () => {
mainWindow && mainWindow.hide()
})
ipcMain.on('update-tray-count', (_, count) => {
updateTrayMenu(count)
})
// Forward FFmpeg events to renderer
ffmpegManager.on('log', (data) => {
mainWindow && mainWindow.webContents.send('stream-log', data)
})
ffmpegManager.on('error', (data) => {
mainWindow && mainWindow.webContents.send('stream-error', data)
updateTrayMenu(ffmpegManager.getActiveCount())
})
ffmpegManager.on('ended', (data) => {
mainWindow && mainWindow.webContents.send('stream-ended', data)
updateTrayMenu(ffmpegManager.getActiveCount())
})
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()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else {
showWindow()
}
})
})
app.on('window-all-closed', (e) => {
// Keep app running in tray
if (process.platform !== 'darwin') {
e.preventDefault()
}
})
app.on('before-quit', () => {
isQuitting = true
ffmpegManager.stopAllStreams()
})