Add support for recording servers
This commit is contained in:
parent
e7e7d159ae
commit
c87c82702d
57
app/ui.js
57
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 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,7 +1460,16 @@ const UI = {
|
|||
header[7] = (data.length >> 8) & 0xff;
|
||||
header[8] = data.length & 0xff;
|
||||
|
||||
// Chain writes to ensure order
|
||||
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);
|
||||
|
|
@ -1438,6 +1480,7 @@ const UI = {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue