Add support for recording servers

This commit is contained in:
Dillon DuPont 2026-01-21 13:05:45 -08:00
parent e7e7d159ae
commit c87c82702d
3 changed files with 284 additions and 20 deletions

View File

@ -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;
}
}
}

View File

@ -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

172
utils/recording_server.py Executable file
View File

@ -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())