From 96b099d892ba19f0dd241ac8eb4d2761475a9423 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 23 Apr 2026 19:02:57 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20remote=20control=20agent=20=E2=80=94=20?= =?UTF-8?q?WebSocket=20client=20+=20Remote=20settings=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/settings.local.json | 8 +- electron/main.js | 82 +++++++++- electron/preload.js | 8 + electron/remote.js | 234 +++++++++++++++++++++++++++++ src/App.jsx | 13 +- src/components/RemoteSettings.jsx | 238 ++++++++++++++++++++++++++++++ src/styles/App.css | 89 +++++++++++ 7 files changed, 667 insertions(+), 5 deletions(-) create mode 100644 electron/remote.js create mode 100644 src/components/RemoteSettings.jsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ba433ba..dea4b72 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,13 @@ "Bash(ls /c/project/SRT_stream/dist-electron/*.exe)", "Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{:*)", "Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{\"tag_name\":\"v1.0.0\",\"name\":\"SRT Streamer v1.0.0\",\"body\":\"Portable Windows EXE\",\"draft\":false,\"prerelease\":false}')", - "Bash(npx vite *)" + "Bash(npx vite *)", + "Bash(git commit -m ' *)", + "Bash(dir dist-electron\\\\*.exe)", + "Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{ *)", + "Bash(python -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('Release ID:', r.get\\('id'\\), '| URL:', r.get\\('html_url'\\)\\)\")", + "Bash(python -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('Asset:', r.get\\('name'\\), '| Size:', r.get\\('size'\\), 'bytes'\\)\")", + "Bash(git checkout *)" ] } } diff --git a/electron/main.js b/electron/main.js index c559cac..8c1c2c8 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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() diff --git a/electron/preload.js b/electron/preload.js index 85c1f41..b724d24 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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) }) diff --git a/electron/remote.js b/electron/remote.js new file mode 100644 index 0000000..31d4e30 --- /dev/null +++ b/electron/remote.js @@ -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 diff --git a/src/App.jsx b/src/App.jsx index 3c44386..632e535 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,7 @@ import StreamCard from './components/StreamCard.jsx' import TolbekSettings from './components/TolbekSettings.jsx' import LogPanel from './components/LogPanel.jsx' import RecordingBar from './components/RecordingBar.jsx' +import RemoteSettings from './components/RemoteSettings.jsx' const ipc = window.electronAPI || null @@ -34,6 +35,7 @@ export default function App() { const [devicesLoaded, setDevicesLoaded] = useState(false) // Global recording settings const [recording, setRecording] = useState({ folder: '', segmentMinutes: 60 }) + const [remote, setRemote] = useState({ enabled: false, server: '', port: '', token: '', machineName: '' }) // Load config and devices on mount useEffect(() => { @@ -45,6 +47,7 @@ export default function App() { } if (config.tolbek) setTolbek(config.tolbek) if (config.recording) setRecording(config.recording) + if (config.remote) setRemote(config.remote) }) ipc.getDevices().then(devs => { setDevices(devs); setDevicesLoaded(true) }) @@ -104,7 +107,8 @@ export default function App() { const saveData = { streams: streams.map(s => { const { status, ...rest } = s; return rest }), tolbek, - recording + recording, + remote } ipc.saveConfig(saveData) ipc.updateTrayCount(streams.filter(s => s.status === 'running').length) @@ -190,6 +194,9 @@ export default function App() { + @@ -243,6 +250,10 @@ export default function App() { )} + {activeTab === 'remote' && ( + + )} + {activeTab === 'logs' && ( setLogs([])} /> )} diff --git a/src/components/RemoteSettings.jsx b/src/components/RemoteSettings.jsx new file mode 100644 index 0000000..06fde0c --- /dev/null +++ b/src/components/RemoteSettings.jsx @@ -0,0 +1,238 @@ +import React, { useState, useEffect, useRef } from 'react' + +const STATUS_LABEL = { + disconnected: 'Не подключено', + connecting: 'Подключение…', + connected: 'Подключено', + error: 'Ошибка', + auth_failed: 'Ошибка авторизации', +} +const STATUS_CLASS = { + disconnected: 'remote-status--off', + connecting: 'remote-status--connecting', + connected: 'remote-status--on', + error: 'remote-status--error', + auth_failed: 'remote-status--error', +} + +export default function RemoteSettings({ config, onChange, ipc }) { + const [status, setStatus] = useState('disconnected') + const [statusInfo, setStatusInfo] = useState(null) + const [previewUrl, setPreviewUrl] = useState('') + const [copied, setCopied] = useState(false) + const debounceRef = useRef(null) + + const { server = '', port = '', token = '', machineName = '', enabled = false } = config + + // Subscribe to remote status events + useEffect(() => { + if (!ipc) return + ipc.onRemoteStatus(({ status: s, ...rest }) => { + setStatus(s) + setStatusInfo(rest) + }) + return () => ipc.removeAllListeners('remote-status') + }, [ipc]) + + // Build URL preview when server/port changes + useEffect(() => { + if (!ipc || !server) { setPreviewUrl(''); return } + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(async () => { + const url = await ipc.remoteGetUrl({ server, port }) + setPreviewUrl(url) + }, 400) + }, [server, port, ipc]) + + // Auto-connect on mount if enabled + useEffect(() => { + if (!ipc || !enabled || !server || !token) return + handleConnect() + }, []) // eslint-disable-line + + async function handleConnect() { + if (!ipc || !server || !token) return + await ipc.remoteConnect({ server, port, token, machineName }) + onChange({ ...config, enabled: true }) + } + + async function handleDisconnect() { + if (!ipc) return + await ipc.remoteDisconnect() + onChange({ ...config, enabled: false }) + } + + async function handleGenerateToken() { + if (!ipc) return + const newToken = await ipc.generateToken() + onChange({ ...config, token: newToken }) + } + + function handleCopyToken() { + navigator.clipboard.writeText(token).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } + + const isConnected = status === 'connected' + const isConnecting = status === 'connecting' + const canConnect = server && token && !isConnected && !isConnecting + + return ( +
+
+

🌐 Удалённое управление

+

+ Подключите приложение к центральному серверу для управления через веб-интерфейс. + Приложение само устанавливает соединение — NAT и проброс портов не нужны. +

+
+ + {/* Connection status */} +
+ + {STATUS_LABEL[status] || status} + {isConnected && statusInfo?.serverName && ( + → {statusInfo.serverName} + )} + {status === 'disconnected' && statusInfo?.retryIn && ( + + повтор через {(statusInfo.retryIn / 1000).toFixed(0)}с + + )} + {status === 'auth_failed' && statusInfo?.reason && ( + {statusInfo.reason} + )} +
+ +
+ {/* Server */} +
+
Сервер
+ +
+
+ + onChange({ ...config, server: e.target.value })} + placeholder="srt.queo.ru или 192.168.1.100" + disabled={isConnected || isConnecting} + /> +
+
+ + onChange({ ...config, port: e.target.value })} + placeholder="443" + disabled={isConnected || isConnecting} + /> +
+
+ + {previewUrl && ( +
+ WebSocket URL: + {previewUrl} +
+ )} +
+ + {/* Token */} +
+
Токен авторизации
+

+ Введите этот токен в веб-интерфейсе при добавлении нового агента. +

+
+ {token || '—'} + + +
+
+ + {/* Machine name */} +
+
Имя машины
+
+ + onChange({ ...config, machineName: e.target.value })} + placeholder="Студия / Офис / Выездной" + disabled={isConnected} + /> +
+
+ + {/* Actions */} +
+ {!isConnected ? ( + + ) : ( + + )} +
+ + {/* Info */} +
+
Как это работает
+
+
+ Агент + + SRT Streamer подключается к серверу и остаётся доступным для управления. + Работает через NAT без проброса портов. + +
+
+ Сервер + + Веб-приложение на {server || 'вашем домене'} — показывает все подключённые + агенты, их устройства, позволяет запускать и останавливать потоки. + +
+
+ Локально + + Без подключения приложение работает в обычном режиме — + удалённое управление опционально. + +
+
+
+
+
+ ) +} diff --git a/src/styles/App.css b/src/styles/App.css index 0adcece..9bd6d2a 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -921,3 +921,92 @@ option:disabled { color: var(--text-muted); } max-width: 220px; margin-left: 4px; } + +/* ========== REMOTE SETTINGS ========== */ +.remote-settings { + max-width: 700px; + margin: 0 auto; +} + +.section-desc { + font-size: 12.5px; + color: var(--text-muted); + line-height: 1.6; + margin-top: 4px; +} + +/* Status bar */ +.remote-status-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: var(--radius); + margin-bottom: 16px; + font-size: 12.5px; + border: 1px solid var(--border); + background: var(--bg-section); +} + +.remote-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-muted); +} + +.remote-status--off .remote-status-dot { background: var(--text-muted); } +.remote-status--on .remote-status-dot { background: var(--green); box-shadow: 0 0 6px var(--green); } +.remote-status--connecting .remote-status-dot { + background: var(--orange); + animation: pulse 1s infinite; +} +.remote-status--error .remote-status-dot { background: var(--red); } + +.remote-status--on { border-color: var(--green); background: var(--green-dim); } +.remote-status--error { border-color: var(--red); background: var(--red-dim); } +.remote-status--connecting { border-color: var(--orange); } + +.remote-status-label { font-weight: 600; } +.remote-status-server { color: var(--text-muted); font-size: 11.5px; } +.remote-status-retry { color: var(--text-muted); font-size: 11px; margin-left: auto; } + +/* Token row */ +.remote-token-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; +} + +.remote-token { + font-family: 'SF Mono', 'Consolas', 'Courier New', monospace; + font-size: 12px; + color: var(--text-code); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 10px; + flex: 1; + min-width: 0; + word-break: break-all; +} + +.btn--danger-outline { + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.4); + color: var(--red); +} +.btn--danger-outline:hover:not(:disabled) { + background: var(--red-dim); + border-color: var(--red); +} + +/* Remote actions */ +.remote-actions { + display: flex; + gap: 10px; + padding: 4px 0 8px; +}