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>
470 lines
14 KiB
JavaScript
470 lines
14 KiB
JavaScript
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()
|
||
})
|