diff --git a/app/ui.js b/app/ui.js index 25ec000a..811c002e 100644 --- a/app/ui.js +++ b/app/ui.js @@ -43,6 +43,7 @@ const UI = { recordingFileHandle: null, recordingWritable: null, recordingWriteQueue: Promise.resolve(), // Chain writes to ensure order + recordingWebSocket: null, // WebSocket for streaming to external server controlbarGrabbed: false, controlbarDrag: false, @@ -209,6 +210,7 @@ const UI = { UI.initSetting('reconnect', false); UI.initSetting('reconnect_delay', 5000); UI.initSetting('autorecord', false); + UI.initSetting('record_url', ''); // WebSocket URL to stream recording to }, // Adds a link to the label elements on the corresponding input elements setupSettingLabels() { @@ -1183,6 +1185,16 @@ const UI = { UI.recording = false; UI.recordingPending = false; + // Close recording WebSocket if streaming to external server + if (UI.recordingWebSocket) { + try { + UI.recordingWebSocket.close(); + } catch (e) { + Log.Error("Error closing recording WebSocket: " + e); + } + UI.recordingWebSocket = null; + } + // Wait for pending writes and close the OPFS file if (UI.recordingWritable) { try { @@ -1395,15 +1407,36 @@ const UI = { url.protocol = (window.location.protocol === "https:") ? 'wss:' : 'ws:'; } - // If recording is pending, set up OPFS file and wrap WebSocket + // If recording is pending, set up recording destination and wrap WebSocket let OriginalWebSocket = null; if (UI.recordingPending) { try { - // Set up OPFS file for recording - const root = await navigator.storage.getDirectory(); - UI.recordingFileHandle = await root.getFileHandle('vnc-recording.bin', { create: true }); - UI.recordingWritable = await UI.recordingFileHandle.createWritable(); - UI.recordingWriteQueue = Promise.resolve(); + const recordUrl = UI.getSetting('record_url'); + + if (recordUrl) { + // Stream recording to external WebSocket server + Log.Info("Setting up recording to external server: " + recordUrl); + UI.recordingWebSocket = new WebSocket(recordUrl); + UI.recordingWebSocket.binaryType = 'arraybuffer'; + + await new Promise((resolve, reject) => { + UI.recordingWebSocket.onopen = () => { + Log.Info("Recording WebSocket connected"); + resolve(); + }; + UI.recordingWebSocket.onerror = (e) => { + reject(new Error("Failed to connect to recording server")); + }; + // Timeout after 5 seconds + setTimeout(() => reject(new Error("Recording server connection timeout")), 5000); + }); + } else { + // Set up OPFS file for local recording + const root = await navigator.storage.getDirectory(); + UI.recordingFileHandle = await root.getFileHandle('vnc-recording.bin', { create: true }); + UI.recordingWritable = await UI.recordingFileHandle.createWritable(); + UI.recordingWriteQueue = Promise.resolve(); + } UI.recording = true; UI.recordingPending = false; @@ -1411,10 +1444,10 @@ const UI = { UI.recordingFrameCount = 0; UI.recordingBytesWritten = 0; - // Helper to write a frame to OPFS (binary format) + // Helper to write a frame (binary format) // Format: fromClient(1) + timestamp(4) + dataLen(4) + data(dataLen) const writeFrame = (fromClient, timestamp, data) => { - if (!UI.recording || !UI.recordingWritable) return; + if (!UI.recording) return; const header = new Uint8Array(9); header[0] = fromClient ? 1 : 0; @@ -1427,17 +1460,27 @@ const UI = { header[7] = (data.length >> 8) & 0xff; header[8] = data.length & 0xff; - // Chain writes to ensure order - UI.recordingWriteQueue = UI.recordingWriteQueue.then(async () => { - try { - await UI.recordingWritable.write(header); - await UI.recordingWritable.write(data); - UI.recordingFrameCount++; - UI.recordingBytesWritten += 9 + data.length; - } catch (e) { - Log.Error("Error writing frame: " + e); - } - }); + if (UI.recordingWebSocket && UI.recordingWebSocket.readyState === WebSocket.OPEN) { + // Stream to external server + const frame = new Uint8Array(9 + data.length); + frame.set(header, 0); + frame.set(data, 9); + UI.recordingWebSocket.send(frame); + UI.recordingFrameCount++; + UI.recordingBytesWritten += frame.length; + } else if (UI.recordingWritable) { + // Write to OPFS + UI.recordingWriteQueue = UI.recordingWriteQueue.then(async () => { + try { + await UI.recordingWritable.write(header); + await UI.recordingWritable.write(data); + UI.recordingFrameCount++; + UI.recordingBytesWritten += 9 + data.length; + } catch (e) { + Log.Error("Error writing frame: " + e); + } + }); + } }; // Wrap WebSocket constructor temporarily @@ -1484,6 +1527,10 @@ const UI = { Log.Error("Failed to set up recording: " + e); UI.showStatus(_("Recording setup failed: ") + e.message, 'error'); UI.recordingPending = false; + if (UI.recordingWebSocket) { + UI.recordingWebSocket.close(); + UI.recordingWebSocket = null; + } } } diff --git a/utils/novnc_proxy b/utils/novnc_proxy index 8ad68620..8e8c7431 100755 --- a/utils/novnc_proxy +++ b/utils/novnc_proxy @@ -42,7 +42,12 @@ usage() { echo " " echo " --password PASS VNC password (passed to vnc.html URL)" echo " --autoconnect auto-connect on page load" - echo " --autorecord auto-record on page load" + echo " --autorecord auto-record on page load (local OPFS storage)" + echo " " + echo " --record-server start recording server for external recording" + echo " --record-port PORT recording server port (default: 6090)" + echo " --record-output FILE recording output file (default: recording.bin)" + echo " --record-js also convert recording to JS format" echo " " exit 2 } @@ -71,6 +76,11 @@ FILEONLY_ARG="" VNC_PASSWORD="" AUTOCONNECT="" AUTORECORD="" +RECORD_SERVER="" +RECORD_PORT="6090" +RECORD_OUTPUT="recording.bin" +RECORD_JS="" +recording_server_pid="" die() { @@ -82,6 +92,10 @@ cleanup() { trap - TERM QUIT INT EXIT trap "true" CHLD # Ignore cleanup messages echo + if [ -n "${recording_server_pid}" ]; then + echo "Terminating recording server (${recording_server_pid})" + kill ${recording_server_pid} 2>/dev/null + fi if [ -n "${proxy_pid}" ]; then echo "Terminating WebSockets proxy (${proxy_pid})" kill ${proxy_pid} @@ -112,6 +126,10 @@ while [ "$*" ]; do --password) VNC_PASSWORD="${OPTARG}"; shift ;; --autoconnect) AUTOCONNECT="1" ;; --autorecord) AUTORECORD="1" ;; + --record-server) RECORD_SERVER="1" ;; + --record-port) RECORD_PORT="${OPTARG}"; shift ;; + --record-output) RECORD_OUTPUT="${OPTARG}"; shift ;; + --record-js) RECORD_JS="1" ;; -h|--help) usage ;; -*) usage "Unknown chrooter option: ${param}" ;; *) break ;; @@ -216,6 +234,28 @@ WEB=`realpath "${WEB}"` [ -n "${CERT}" ] && CERT=`realpath "${CERT}"` [ -n "${KEY}" ] && KEY=`realpath "${KEY}"` [ -n "${RECORD}" ] && RECORD=`realpath "${RECORD}"` +[ -n "${RECORD_OUTPUT}" ] && RECORD_OUTPUT=`realpath "${RECORD_OUTPUT}"` + +# Start recording server if requested +if [ -n "${RECORD_SERVER}" ]; then + RECORD_SCRIPT="${HERE}/recording_server.py" + if [ ! -f "${RECORD_SCRIPT}" ]; then + die "Recording server script not found: ${RECORD_SCRIPT}" + fi + + echo "Starting recording server on port ${RECORD_PORT}..." + RECORD_JS_ARG="" + [ -n "${RECORD_JS}" ] && RECORD_JS_ARG="--js" + python3 "${RECORD_SCRIPT}" --port "${RECORD_PORT}" --output "${RECORD_OUTPUT}" ${RECORD_JS_ARG} & + recording_server_pid="$!" + sleep 1 + + if [ -z "$recording_server_pid" ] || ! ps -eo pid= | grep -w "$recording_server_pid" > /dev/null; then + recording_server_pid= + die "Failed to start recording server" + fi + echo "Recording server started (PID: ${recording_server_pid})" +fi echo "Starting webserver and WebSockets proxy on${HOST:+ host ${HOST}} port ${PORT}" ${WEBSOCKIFY} ${SYSLOG_ARG} ${SSLONLY} ${FILEONLY_ARG} --web ${WEB} ${CERT:+--cert ${CERT}} ${KEY:+--key ${KEY}} ${LISTEN} ${VNC_DEST} ${HEARTBEAT_ARG} ${IDLETIMEOUT_ARG} ${RECORD:+--record ${RECORD}} ${TIMEOUT_ARG} ${WEBAUTH_ARG} ${AUTHPLUGIN_ARG} ${AUTHSOURCE_ARG} & @@ -236,6 +276,11 @@ URL_PARAMS="host=${HOST}&port=${PORT}" [ -n "$VNC_PASSWORD" ] && URL_PARAMS="${URL_PARAMS}&password=${VNC_PASSWORD}" [ -n "$AUTOCONNECT" ] && URL_PARAMS="${URL_PARAMS}&autoconnect=1" [ -n "$AUTORECORD" ] && URL_PARAMS="${URL_PARAMS}&autorecord=1" +# If recording server is running, add record_url (autorecord must also be set for streaming) +if [ -n "$RECORD_SERVER" ]; then + RECORD_URL="ws://${HOST}:${RECORD_PORT}" + URL_PARAMS="${URL_PARAMS}&record_url=${RECORD_URL}&autorecord=1" +fi if [ "x$SSLONLY" == "x" ]; then echo -e " http://${HOST}:${PORT}/vnc.html?${URL_PARAMS}\n" else diff --git a/utils/recording_server.py b/utils/recording_server.py new file mode 100755 index 00000000..92398b12 --- /dev/null +++ b/utils/recording_server.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +VNC Recording Server + +A WebSocket server that receives VNC recording frames from noVNC +and saves them to a file. + +Usage: + python recording_server.py [--port PORT] [--output FILE] + + --port PORT Port to listen on (default: 6090) + --output FILE Output file path (default: recording.bin) + --js Also convert to JS playback format when done + +Example: + python recording_server.py --port 6090 --output demo.bin --js + +Then in noVNC, use: + vnc.html?host=...&autoconnect=1&autorecord=1&record_url=ws://localhost:6090 +""" + +import argparse +import asyncio +import base64 +import signal +import struct +import sys +from datetime import datetime +from pathlib import Path + +try: + import websockets +except ImportError: + print("Error: websockets library not found. Install with: pip install websockets") + sys.exit(1) + + +class RecordingServer: + def __init__(self, output_path: str, convert_to_js: bool = False): + self.output_path = Path(output_path) + self.convert_to_js = convert_to_js + self.file = None + self.frame_count = 0 + self.bytes_written = 0 + self.client_connected = False + self.start_time = None + + async def handle_client(self, websocket): + """Handle a single WebSocket client connection.""" + if self.client_connected: + print("Warning: Another client is already connected, rejecting new connection") + await websocket.close(1008, "Another client already connected") + return + + self.client_connected = True + self.frame_count = 0 + self.bytes_written = 0 + self.start_time = datetime.now() + + # Open output file + self.file = open(self.output_path, 'wb') + + client_addr = websocket.remote_address + print(f"[{self.start_time.strftime('%H:%M:%S')}] Client connected from {client_addr}") + print(f"Recording to: {self.output_path}") + + try: + async for message in websocket: + if isinstance(message, bytes): + self.file.write(message) + self.frame_count += 1 + self.bytes_written += len(message) + + # Progress update every 1000 frames + if self.frame_count % 1000 == 0: + mb = self.bytes_written / (1024 * 1024) + print(f" Received {self.frame_count} frames ({mb:.2f} MB)") + + except websockets.exceptions.ConnectionClosed as e: + print(f"Connection closed: {e.code} {e.reason}") + finally: + self.file.close() + self.client_connected = False + + duration = (datetime.now() - self.start_time).total_seconds() + mb = self.bytes_written / (1024 * 1024) + print(f"Recording complete: {self.frame_count} frames, {mb:.2f} MB in {duration:.1f}s") + + if self.convert_to_js: + await self.convert_to_js_format() + + async def convert_to_js_format(self): + """Convert binary recording to JS playback format.""" + js_path = self.output_path.with_suffix('.js') + print(f"Converting to JS format: {js_path}") + + frames = [] + with open(self.output_path, 'rb') as f: + while True: + header = f.read(9) + if len(header) < 9: + break + + from_client = header[0] == 1 + timestamp = struct.unpack('>I', header[1:5])[0] # big endian + data_len = struct.unpack('>I', header[5:9])[0] # big endian + + data = f.read(data_len) + if len(data) < data_len: + print(f"Warning: Truncated frame, expected {data_len} bytes, got {len(data)}") + break + + # Convert to base64 + b64_data = base64.b64encode(data).decode('ascii') + + # Format: "{timestamp{base64data" for server, "}timestamp{base64data" for client + prefix = '}' if from_client else '{' + frame_str = f'{prefix}{timestamp}{{{b64_data}' + frames.append(f'"{frame_str}"') + + frames.append('"EOF"') + + # Write JS file + with open(js_path, 'w') as f: + f.write(f'/* Recorded VNC session - {datetime.now().isoformat()} */\n') + f.write(f'/* {len(frames) - 1} frames */\n') + f.write('var VNC_frame_data = [\n') + f.write(',\n'.join(frames)) + f.write('\n];\n') + + print(f"JS conversion complete: {js_path}") + + +async def main(): + parser = argparse.ArgumentParser(description='VNC Recording Server') + parser.add_argument('--port', type=int, default=6090, help='Port to listen on (default: 6090)') + parser.add_argument('--output', type=str, default='recording.bin', help='Output file path') + parser.add_argument('--js', action='store_true', help='Convert to JS format when recording ends') + parser.add_argument('--host', type=str, default='0.0.0.0', help='Host to bind to (default: 0.0.0.0)') + args = parser.parse_args() + + server = RecordingServer(args.output, convert_to_js=args.js) + + # Handle graceful shutdown + stop_event = asyncio.Event() + + def signal_handler(): + print("\nShutting down...") + stop_event.set() + + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, signal_handler) + + print(f"VNC Recording Server") + print(f"====================") + print(f"Listening on ws://{args.host}:{args.port}") + print(f"Output file: {args.output}") + print(f"JS conversion: {'enabled' if args.js else 'disabled'}") + print() + print("Use in noVNC with URL parameter:") + print(f" record_url=ws://localhost:{args.port}") + print() + print("Press Ctrl+C to stop") + print() + + async with websockets.serve(server.handle_client, args.host, args.port): + await stop_event.wait() + + +if __name__ == '__main__': + asyncio.run(main())