Add session recorder to noVNC

This commit is contained in:
Dillon DuPont 2026-01-21 12:35:12 -08:00
parent d5b928f8b6
commit e7e7d159ae
9 changed files with 1803 additions and 5 deletions

13
app/images/record.svg Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 25 25"
version="1.1">
<circle
cx="12.5"
cy="12.5"
r="8"
style="fill:#ffffff;fill-opacity:1;stroke:none" />
</svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -605,6 +605,22 @@ html {
max-height: calc(100vh - 10em - 25px); max-height: calc(100vh - 10em - 25px);
} }
/* Recording */
#noVNC_record_button.noVNC_recording {
border-color: #ff4444;
background-color: rgba(255, 68, 68, 0.3);
}
#noVNC_record_button.noVNC_recording img {
filter: brightness(0) saturate(100%) invert(35%) sepia(100%) saturate(3000%) hue-rotate(340deg) brightness(100%) contrast(100%);
}
#noVNC_record p {
margin: 5px 0;
}
#noVNC_record input[type=button] {
width: 100%;
margin-top: 5px;
}
/* Settings */ /* Settings */
#noVNC_settings { #noVNC_settings {
} }

449
app/ui.js
View File

@ -34,6 +34,16 @@ const UI = {
idleControlbarTimeout: null, idleControlbarTimeout: null,
closeControlbarTimeout: null, closeControlbarTimeout: null,
// Recording state
recording: false,
recordingPending: false, // True if recording should start on next connect
recordingStartTime: null,
recordingFrameCount: 0,
recordingBytesWritten: 0,
recordingFileHandle: null,
recordingWritable: null,
recordingWriteQueue: Promise.resolve(), // Chain writes to ensure order
controlbarGrabbed: false, controlbarGrabbed: false,
controlbarDrag: false, controlbarDrag: false,
controlbarMouseDownClientY: 0, controlbarMouseDownClientY: 0,
@ -121,6 +131,7 @@ const UI = {
UI.addConnectionControlHandlers(); UI.addConnectionControlHandlers();
UI.addClipboardHandlers(); UI.addClipboardHandlers();
UI.addSettingsHandlers(); UI.addSettingsHandlers();
UI.addRecordingHandlers();
document.getElementById("noVNC_status") document.getElementById("noVNC_status")
.addEventListener('click', UI.hideStatus); .addEventListener('click', UI.hideStatus);
@ -133,6 +144,14 @@ const UI = {
document.documentElement.classList.remove("noVNC_loading"); document.documentElement.classList.remove("noVNC_loading");
// Check for autorecord setting
let autorecord = UI.getSetting('autorecord');
if (autorecord === 'true' || autorecord == '1') {
UI.recordingPending = true;
document.getElementById('noVNC_record_button').classList.add('noVNC_selected');
document.getElementById('noVNC_record_button').classList.add('noVNC_recording');
}
let autoconnect = UI.getSetting('autoconnect'); let autoconnect = UI.getSetting('autoconnect');
if (autoconnect === 'true' || autoconnect == '1') { if (autoconnect === 'true' || autoconnect == '1') {
autoconnect = true; autoconnect = true;
@ -189,6 +208,7 @@ const UI = {
UI.initSetting('repeaterID', ''); UI.initSetting('repeaterID', '');
UI.initSetting('reconnect', false); UI.initSetting('reconnect', false);
UI.initSetting('reconnect_delay', 5000); UI.initSetting('reconnect_delay', 5000);
UI.initSetting('autorecord', false);
}, },
// Adds a link to the label elements on the corresponding input elements // Adds a link to the label elements on the corresponding input elements
setupSettingLabels() { setupSettingLabels() {
@ -381,6 +401,32 @@ const UI = {
UI.addSettingChangeHandler('reconnect_delay'); UI.addSettingChangeHandler('reconnect_delay');
}, },
addRecordingHandlers() {
document.getElementById("noVNC_record_button")
.addEventListener('click', UI.toggleRecordingPanel);
document.getElementById("noVNC_record_start_button")
.addEventListener('click', UI.startRecording);
document.getElementById("noVNC_record_stop_button")
.addEventListener('click', UI.stopRecording);
document.getElementById("noVNC_record_download_button")
.addEventListener('click', UI.downloadRecording);
// Ensure recording is properly closed when page unloads
const closeRecordingFile = () => {
if (UI.recording && UI.recordingWritable) {
UI.recording = false;
UI.recordingWriteQueue.then(() => {
if (UI.recordingWritable) {
UI.recordingWritable.close();
UI.recordingWritable = null;
}
});
}
};
window.addEventListener('beforeunload', closeRecordingFile);
window.addEventListener('pagehide', closeRecordingFile);
},
addFullscreenHandlers() { addFullscreenHandlers() {
document.getElementById("noVNC_fullscreen_button") document.getElementById("noVNC_fullscreen_button")
.addEventListener('click', UI.toggleFullscreen); .addEventListener('click', UI.toggleFullscreen);
@ -867,6 +913,7 @@ const UI = {
UI.closePowerPanel(); UI.closePowerPanel();
UI.closeClipboardPanel(); UI.closeClipboardPanel();
UI.closeExtraKeys(); UI.closeExtraKeys();
UI.closeRecordingPanel();
}, },
/* ------^------- /* ------^-------
@ -1010,6 +1057,282 @@ const UI = {
/* ------^------- /* ------^-------
* /CLIPBOARD * /CLIPBOARD
* ============== * ==============
* RECORDING
* ------v------*/
openRecordingPanel() {
UI.closeAllPanels();
UI.openControlbar();
UI.updateRecordingStats();
document.getElementById('noVNC_record')
.classList.add("noVNC_open");
document.getElementById('noVNC_record_button')
.classList.add("noVNC_selected");
},
closeRecordingPanel() {
document.getElementById('noVNC_record')
.classList.remove("noVNC_open");
document.getElementById('noVNC_record_button')
.classList.remove("noVNC_selected");
},
toggleRecordingPanel() {
if (document.getElementById('noVNC_record')
.classList.contains("noVNC_open")) {
UI.closeRecordingPanel();
} else {
UI.openRecordingPanel();
}
},
updateRecordingStats() {
const statusElem = document.getElementById('noVNC_record_status');
const statsElem = document.getElementById('noVNC_record_stats');
if (UI.recording) {
const elapsed = Math.floor((Date.now() - UI.recordingStartTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
statusElem.textContent = `Recording: ${minutes}:${seconds.toString().padStart(2, '0')}`;
statusElem.style.color = '#ff4444';
const mbytes = (UI.recordingBytesWritten / (1024 * 1024)).toFixed(2);
statsElem.textContent = `Frames: ${UI.recordingFrameCount}, Size: ${mbytes} MB (OPFS)`;
} else if (UI.recordingPending) {
statusElem.textContent = 'Waiting for connection...';
statusElem.style.color = '#ffaa00';
statsElem.textContent = 'Recording will start when you connect';
} else {
statusElem.textContent = 'Not recording';
statusElem.style.color = '';
if (UI.recordingFrameCount > 0) {
const mbytes = (UI.recordingBytesWritten / (1024 * 1024)).toFixed(2);
statsElem.textContent = `Recorded: ${UI.recordingFrameCount} frames, ${mbytes} MB`;
} else {
statsElem.textContent = '';
}
}
// Update storage usage (async)
UI.updateStorageUsage();
},
async updateStorageUsage() {
const storageElem = document.getElementById('noVNC_record_storage');
if (!storageElem) return;
try {
const s = await navigator.storage.estimate();
const usageGB = (s.usage / 1e9).toFixed(2);
const quotaGB = (s.quota / 1e9).toFixed(1);
const percent = (s.usage / s.quota * 100).toFixed(1);
storageElem.textContent = `Storage: ${usageGB}GB / ${quotaGB}GB (${percent}%)`;
} catch (e) {
storageElem.textContent = 'Storage: unavailable';
}
},
async startRecording() {
if (UI.recording) {
return;
}
// If already connected, user needs to reconnect to capture from beginning
if (UI.connected) {
UI.showStatus(_("Disconnect and reconnect to record from the beginning"), 'warn');
return;
}
// Delete any existing recording file
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry('vnc-recording.bin');
} catch (e) {
// File doesn't exist, that's fine
}
// Set pending - recording will start when connection is made
UI.recordingPending = true;
UI.recordingFrameCount = 0;
UI.recordingBytesWritten = 0;
// Update UI to show pending state
document.getElementById('noVNC_record_button').classList.add('noVNC_selected');
document.getElementById('noVNC_record_button').classList.add('noVNC_recording');
document.getElementById('noVNC_record_start_button').disabled = true;
document.getElementById('noVNC_record_stop_button').disabled = false;
document.getElementById('noVNC_record_download_button').disabled = true;
document.getElementById('noVNC_record_status').textContent = 'Waiting for connection...';
document.getElementById('noVNC_record_status').style.color = '#ffaa00';
UI.showStatus(_("Recording will start when you connect"), 'normal');
},
async stopRecording() {
if (!UI.recording && !UI.recordingPending) {
return;
}
Log.Info("Stopping recording, captured " + UI.recordingFrameCount + " frames");
// Setting recording to false stops the wrapped handlers from capturing
UI.recording = false;
UI.recordingPending = false;
// Wait for pending writes and close the OPFS file
if (UI.recordingWritable) {
try {
await UI.recordingWriteQueue; // Wait for all pending writes
await UI.recordingWritable.close();
} catch (e) {
Log.Error("Error closing recording file: " + e);
}
UI.recordingWritable = null;
UI.recordingFileHandle = null;
}
// Stop stats update
if (UI.recordingStatsInterval) {
clearInterval(UI.recordingStatsInterval);
UI.recordingStatsInterval = null;
}
// Update UI
document.getElementById('noVNC_record_button').classList.remove('noVNC_recording');
if (!document.getElementById('noVNC_record').classList.contains('noVNC_open')) {
document.getElementById('noVNC_record_button').classList.remove('noVNC_selected');
}
document.getElementById('noVNC_record_start_button').disabled = false;
document.getElementById('noVNC_record_stop_button').disabled = true;
document.getElementById('noVNC_record_download_button').disabled = (UI.recordingFrameCount === 0);
UI.updateRecordingStats();
UI.showStatus(_("Recording stopped: ") + UI.recordingFrameCount + _(" frames captured"), 'normal');
},
async downloadRecording() {
if (UI.recordingFrameCount === 0) {
UI.showStatus(_("No recording to download"), 'error');
return;
}
Log.Info("Generating recording file with " + UI.recordingFrameCount + " frames");
UI.showStatus(_("Preparing download..."), 'normal');
try {
// Open the OPFS recording file for reading
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('vnc-recording.bin');
const file = await fileHandle.getFile();
// Create a streaming response that converts binary to JS format
const reader = file.stream().getReader();
const textEncoder = new TextEncoder();
let buffer = new Uint8Array(0);
let framesProcessed = 0;
let headerWritten = false;
const jsStream = new ReadableStream({
async pull(controller) {
// Write header first
if (!headerWritten) {
const header = '/* noVNC recording - generated by noVNC client-side recorder */\n' +
'/* eslint-disable */\n' +
'var VNC_frame_data = [\n';
controller.enqueue(textEncoder.encode(header));
headerWritten = true;
}
// Read and process frames
while (true) {
// Try to parse a frame from buffer
// Binary format: fromClient(1) + timestamp(4) + dataLen(4) + data(dataLen)
if (buffer.length >= 9) {
const fromClient = buffer[0] === 1;
const timestamp = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
const dataLen = (buffer[5] << 24) | (buffer[6] << 16) | (buffer[7] << 8) | buffer[8];
if (buffer.length >= 9 + dataLen) {
// We have a complete frame
const data = buffer.slice(9, 9 + dataLen);
buffer = buffer.slice(9 + dataLen);
// Convert to JS format
const prefix = fromClient ? '}' : '{';
let binary = '';
for (let j = 0; j < data.length; j++) {
binary += String.fromCharCode(data[j]);
}
const base64Data = btoa(binary);
const frameStr = prefix + timestamp + '{' + base64Data;
const escaped = JSON.stringify(frameStr);
const line = escaped + ',\n';
controller.enqueue(textEncoder.encode(line));
framesProcessed++;
// Update status periodically
if (framesProcessed % 1000 === 0) {
UI.showStatus(_("Processing: ") + framesProcessed + "/" + UI.recordingFrameCount + _(" frames"), 'normal');
}
continue; // Try to parse another frame
}
}
// Need more data
const { done, value } = await reader.read();
if (done) {
// Write footer and close
controller.enqueue(textEncoder.encode('"EOF"\n];\n'));
controller.close();
return;
}
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
}
}
});
// Create blob from stream and trigger download
const response = new Response(jsStream);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const now = new Date();
const dateStr = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
a.download = 'vnc-recording-' + dateStr + '.js';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
UI.showStatus(_("Recording downloaded: ") + framesProcessed + _(" frames"), 'normal');
} catch (e) {
Log.Error("Error downloading recording: " + e);
UI.showStatus(_("Download error: ") + e.message, 'error');
}
},
/* ------^-------
* /RECORDING
* ==============
* CONNECTION * CONNECTION
* ------v------*/ * ------v------*/
@ -1023,7 +1346,7 @@ const UI = {
.classList.remove("noVNC_open"); .classList.remove("noVNC_open");
}, },
connect(event, password) { async connect(event, password) {
// Ignore when rfb already exists // Ignore when rfb already exists
if (typeof UI.rfb !== 'undefined') { if (typeof UI.rfb !== 'undefined') {
@ -1072,6 +1395,98 @@ const UI = {
url.protocol = (window.location.protocol === "https:") ? 'wss:' : 'ws:'; url.protocol = (window.location.protocol === "https:") ? 'wss:' : 'ws:';
} }
// If recording is pending, set up OPFS file 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();
UI.recording = true;
UI.recordingPending = false;
UI.recordingStartTime = Date.now();
UI.recordingFrameCount = 0;
UI.recordingBytesWritten = 0;
// Helper to write a frame to OPFS (binary format)
// Format: fromClient(1) + timestamp(4) + dataLen(4) + data(dataLen)
const writeFrame = (fromClient, timestamp, data) => {
if (!UI.recording || !UI.recordingWritable) return;
const header = new Uint8Array(9);
header[0] = fromClient ? 1 : 0;
header[1] = (timestamp >> 24) & 0xff;
header[2] = (timestamp >> 16) & 0xff;
header[3] = (timestamp >> 8) & 0xff;
header[4] = timestamp & 0xff;
header[5] = (data.length >> 24) & 0xff;
header[6] = (data.length >> 16) & 0xff;
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);
}
});
};
// Wrap WebSocket constructor temporarily
OriginalWebSocket = window.WebSocket;
window.WebSocket = function(wsUrl, protocols) {
const ws = new OriginalWebSocket(wsUrl, protocols);
// Capture server-to-client messages
ws.addEventListener('message', function(e) {
if (UI.recording) {
const timestamp = Date.now() - UI.recordingStartTime;
const data = new Uint8Array(e.data);
writeFrame(false, timestamp, data);
}
});
// Wrap send for client-to-server messages
const originalSend = ws.send.bind(ws);
ws.send = function(data) {
if (UI.recording) {
const timestamp = Date.now() - UI.recordingStartTime;
let u8data;
if (data instanceof ArrayBuffer) {
u8data = new Uint8Array(data);
} else if (data instanceof Uint8Array) {
u8data = data;
} else {
u8data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
writeFrame(true, timestamp, u8data);
}
originalSend(data);
};
return ws;
};
// Copy static properties
window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING;
window.WebSocket.OPEN = OriginalWebSocket.OPEN;
window.WebSocket.CLOSING = OriginalWebSocket.CLOSING;
window.WebSocket.CLOSED = OriginalWebSocket.CLOSED;
} catch (e) {
Log.Error("Failed to set up recording: " + e);
UI.showStatus(_("Recording setup failed: ") + e.message, 'error');
UI.recordingPending = false;
}
}
try { try {
UI.rfb = new RFB(document.getElementById('noVNC_container'), UI.rfb = new RFB(document.getElementById('noVNC_container'),
url.href, url.href,
@ -1085,6 +1500,28 @@ const UI = {
return; return;
} }
// Restore original WebSocket if we wrapped it
if (OriginalWebSocket) {
window.WebSocket = OriginalWebSocket;
// Update recording UI
document.getElementById('noVNC_record_button').classList.add('noVNC_selected');
document.getElementById('noVNC_record_button').classList.add('noVNC_recording');
document.getElementById('noVNC_record_start_button').disabled = true;
document.getElementById('noVNC_record_stop_button').disabled = false;
document.getElementById('noVNC_record_download_button').disabled = true;
UI.updateRecordingStats();
// Start periodic stats update
UI.recordingStatsInterval = setInterval(() => {
if (document.getElementById('noVNC_record').classList.contains('noVNC_open')) {
UI.updateRecordingStats();
}
}, 1000);
UI.showStatus(_("Recording started (OPFS)"), 'normal');
}
UI.rfb.addEventListener("connect", UI.connectFinished); UI.rfb.addEventListener("connect", UI.connectFinished);
UI.rfb.addEventListener("disconnect", UI.disconnectFinished); UI.rfb.addEventListener("disconnect", UI.disconnectFinished);
UI.rfb.addEventListener("serververification", UI.serverVerify); UI.rfb.addEventListener("serververification", UI.serverVerify);
@ -1106,6 +1543,11 @@ const UI = {
}, },
disconnect() { disconnect() {
// Stop recording if active or pending
if (UI.recording || UI.recordingPending) {
UI.stopRecording();
}
UI.rfb.disconnect(); UI.rfb.disconnect();
UI.connected = false; UI.connected = false;
@ -1161,6 +1603,11 @@ const UI = {
disconnectFinished(e) { disconnectFinished(e) {
const wasConnected = UI.connected; const wasConnected = UI.connected;
// Stop recording if active or pending (handles unexpected disconnects)
if (UI.recording || UI.recordingPending) {
UI.stopRecording();
}
// This variable is ideally set when disconnection starts, but // This variable is ideally set when disconnection starts, but
// when the disconnection isn't clean or if it is initiated by // when the disconnection isn't clean or if it is initiated by
// the server, we need to do it here as well since // the server, we need to do it here as well since

413
demo_collector.html Normal file
View File

@ -0,0 +1,413 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demonstration Collector</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
h1 {
margin-bottom: 10px;
color: #fff;
}
.subtitle {
color: #888;
margin-bottom: 40px;
}
.container {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
max-width: 400px;
}
button {
padding: 20px 40px;
font-size: 18px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.collect-btn {
background: #4CAF50;
color: white;
}
.collect-btn:hover:not(:disabled) {
background: #45a049;
transform: translateY(-2px);
}
.collect-btn.recording {
background: #f44336;
animation: pulse 1.5s infinite;
}
.download-btn {
background: #2196F3;
color: white;
}
.download-btn:hover:not(:disabled) {
background: #1976D2;
transform: translateY(-2px);
}
.status {
text-align: center;
padding: 15px;
background: #252540;
border-radius: 8px;
min-height: 80px;
}
.status-label {
color: #888;
font-size: 12px;
text-transform: uppercase;
margin-bottom: 8px;
}
.status-value {
font-size: 16px;
font-family: monospace;
}
.storage-info {
font-size: 12px;
color: #666;
margin-top: 10px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.config {
background: #252540;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
}
.config label {
display: block;
margin-bottom: 8px;
color: #888;
font-size: 12px;
text-transform: uppercase;
}
.config input {
width: 100%;
padding: 10px;
border: 1px solid #444;
border-radius: 4px;
background: #1a1a2e;
color: #fff;
font-size: 14px;
}
.config input:focus {
outline: none;
border-color: #4CAF50;
}
</style>
</head>
<body>
<h1>Demonstration Collector</h1>
<p class="subtitle">Record VNC sessions for training data</p>
<div class="container">
<div class="config">
<label>VNC URL (host:port)</label>
<input type="text" id="vncUrl" placeholder="localhost:6081" value="localhost:6081">
</div>
<div class="config">
<label>VNC Password (optional)</label>
<input type="password" id="vncPassword" placeholder="Leave empty if none">
</div>
<button class="collect-btn" id="collectBtn" onclick="toggleCollection()">
Collect Demonstration
</button>
<button class="download-btn" id="downloadBtn" onclick="downloadDemo()">
Download Demonstration
</button>
<div class="status">
<div class="status-label">Status</div>
<div class="status-value" id="statusText">Ready</div>
<div class="storage-info" id="storageInfo"></div>
</div>
</div>
<script>
let vncWindow = null;
let isCollecting = false;
let statusCheckInterval = null;
// Update storage info on load and periodically
updateStorageInfo();
setInterval(updateStorageInfo, 2000);
async function updateStorageInfo() {
const storageElem = document.getElementById('storageInfo');
try {
const s = await navigator.storage.estimate();
const usageGB = (s.usage / 1e9).toFixed(3);
const quotaGB = (s.quota / 1e9).toFixed(1);
storageElem.textContent = `Storage: ${usageGB}GB / ${quotaGB}GB`;
// Also check if there's a recording file
await checkRecordingFile();
} catch (e) {
storageElem.textContent = 'Storage: unavailable';
}
}
async function checkRecordingFile() {
const downloadBtn = document.getElementById('downloadBtn');
const statusText = document.getElementById('statusText');
try {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('vnc-recording.bin');
const file = await fileHandle.getFile();
if (file.size > 0 && !isCollecting) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
downloadBtn.disabled = false;
if (!isCollecting) {
statusText.textContent = `Recording available: ${sizeMB} MB`;
}
}
} catch (e) {
// No recording file exists
if (!isCollecting) {
downloadBtn.disabled = true;
}
}
}
function toggleCollection() {
if (isCollecting) {
stopCollection();
} else {
startCollection();
}
}
function startCollection() {
const vncUrl = document.getElementById('vncUrl').value || 'localhost:6081';
const password = document.getElementById('vncPassword').value;
// Parse host and port
const [host, port] = vncUrl.includes(':') ? vncUrl.split(':') : [vncUrl, '6081'];
// Build the vnc.html URL with autoconnect and autorecord
let url = `vnc.html?host=${encodeURIComponent(host)}&port=${encodeURIComponent(port)}&autoconnect=1&autorecord=1`;
if (password) {
url += `&password=${encodeURIComponent(password)}`;
}
// Open in a popup window (PiP-like)
const width = 1024;
const height = 768;
const left = window.screen.width - width - 50;
const top = 50;
vncWindow = window.open(
url,
'vnc_demo',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=no`
);
if (vncWindow) {
isCollecting = true;
updateUI();
// Monitor if window is closed
statusCheckInterval = setInterval(() => {
if (vncWindow.closed) {
stopCollection();
}
}, 500);
} else {
alert('Failed to open VNC window. Please allow popups for this site.');
}
}
function stopCollection() {
isCollecting = false;
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
statusCheckInterval = null;
}
if (vncWindow && !vncWindow.closed) {
vncWindow.close();
}
vncWindow = null;
updateUI();
// Wait a moment for OPFS to sync, then check for file
setTimeout(updateStorageInfo, 500);
}
function updateUI() {
const collectBtn = document.getElementById('collectBtn');
const downloadBtn = document.getElementById('downloadBtn');
const statusText = document.getElementById('statusText');
if (isCollecting) {
collectBtn.textContent = 'Stop Collection';
collectBtn.classList.add('recording');
downloadBtn.disabled = true;
statusText.textContent = 'Recording in progress...';
} else {
collectBtn.textContent = 'Collect Demonstration';
collectBtn.classList.remove('recording');
statusText.textContent = 'Ready';
}
}
async function downloadDemo() {
const statusText = document.getElementById('statusText');
statusText.textContent = 'Preparing download...';
try {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('vnc-recording.bin');
const file = await fileHandle.getFile();
if (file.size === 0) {
statusText.textContent = 'No recording data found';
return;
}
statusText.textContent = 'Converting to playback format...';
// Convert binary OPFS format to JS playback format
const jsContent = await convertToPlaybackFormat(file);
// Download as .js file
const blob = new Blob([jsContent], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `demo_${new Date().toISOString().replace(/[:.]/g, '-')}.js`;
a.click();
URL.revokeObjectURL(url);
statusText.textContent = 'Download complete!';
// Ask if user wants to clear the recording
if (confirm('Download complete! Clear the recording to free up storage?')) {
await clearRecording();
}
} catch (e) {
console.error('Download error:', e);
statusText.textContent = 'Error: ' + e.message;
}
}
async function convertToPlaybackFormat(file) {
const statusText = document.getElementById('statusText');
const totalSize = file.size;
console.log('Converting file, size:', totalSize);
if (totalSize === 0) {
throw new Error('Recording file is empty');
}
// Read entire file into memory (simpler than chunked approach)
const buffer = await file.arrayBuffer();
const view = new DataView(buffer);
let pos = 0;
let frames = [];
let frameCount = 0;
while (pos + 9 <= buffer.byteLength) {
const fromClient = view.getUint8(pos) === 1;
const timestamp = view.getUint32(pos + 1, false); // big endian
const dataLen = view.getUint32(pos + 5, false); // big endian
console.log(`Frame ${frameCount}: fromClient=${fromClient}, timestamp=${timestamp}, dataLen=${dataLen}, pos=${pos}`);
if (dataLen > 100 * 1024 * 1024) {
// Sanity check - frame shouldn't be larger than 100MB
throw new Error(`Invalid frame at pos ${pos}: dataLen=${dataLen} is too large`);
}
if (pos + 9 + dataLen > buffer.byteLength) {
console.warn(`Truncated frame at pos ${pos}, needed ${dataLen} bytes but only ${buffer.byteLength - pos - 9} available`);
break;
}
const data = new Uint8Array(buffer, pos + 9, dataLen);
// Convert to base64
let binary = '';
for (let i = 0; i < data.length; i++) {
binary += String.fromCharCode(data[i]);
}
const base64 = btoa(binary);
// Format: "{timestamp{base64data" for server, "}timestamp{base64data" for client
const prefix = fromClient ? '}' : '{';
frames.push(`"${prefix}${timestamp}{${base64}"`);
pos += 9 + dataLen;
frameCount++;
// Update status every 100 frames
if (frameCount % 100 === 0) {
const percent = ((pos / totalSize) * 100).toFixed(0);
statusText.textContent = `Converting: ${percent}% (${frameCount} frames)`;
// Yield to UI
await new Promise(r => setTimeout(r, 0));
}
}
console.log(`Conversion complete: ${frameCount} frames`);
frames.push('"EOF"');
return `/* Recorded VNC session - ${new Date().toISOString()} */\n` +
`/* ${frameCount} frames, ${(file.size / 1024 / 1024).toFixed(2)} MB */\n` +
`var VNC_frame_data = [\n${frames.join(',\n')}\n];\n`;
}
async function clearRecording() {
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry('vnc-recording.bin');
document.getElementById('statusText').textContent = 'Recording cleared';
document.getElementById('downloadBtn').disabled = true;
await updateStorageInfo();
} catch (e) {
console.error('Clear error:', e);
}
}
// Handle page unload
window.addEventListener('beforeunload', () => {
if (vncWindow && !vncWindow.closed) {
vncWindow.close();
}
});
</script>
</body>
</html>

736
tests/benchmark_buffer.html Normal file
View File

@ -0,0 +1,736 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Recording Buffer Benchmark</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 20px;
background: #1a1a2e;
color: #eee;
}
h1 { color: #00d4ff; }
.config {
background: #16213e;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.config label {
display: block;
margin-bottom: 10px;
}
.config input[type="number"] {
width: 100px;
padding: 8px;
border: none;
border-radius: 4px;
background: #0f3460;
color: #fff;
}
.methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.method {
background: #16213e;
padding: 20px;
border-radius: 8px;
border: 2px solid #0f3460;
}
.method h3 {
margin-top: 0;
color: #00d4ff;
}
.method p {
font-size: 0.9em;
color: #aaa;
margin-bottom: 15px;
}
button {
background: #e94560;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { background: #ff6b6b; }
button:disabled {
background: #555;
cursor: not-allowed;
}
.status {
margin-top: 10px;
padding: 10px;
background: #0f3460;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
min-height: 60px;
white-space: pre-wrap;
}
.progress {
height: 20px;
background: #0f3460;
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #e94560);
width: 0%;
transition: width 0.1s;
}
#results {
background: #16213e;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
#results h3 { color: #00d4ff; margin-top: 0; }
#resultsTable {
width: 100%;
border-collapse: collapse;
}
#resultsTable th, #resultsTable td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #0f3460;
}
#resultsTable th { color: #00d4ff; }
.success { color: #4ade80; }
.failure { color: #f87171; }
</style>
</head>
<body>
<h1>Recording Buffer Benchmark</h1>
<p>Test different storage methods for high-throughput VNC recording (~10MB/s)</p>
<div class="config">
<label>
Target data size (GB):
<input type="number" id="targetGB" value="0.5" min="0.1" max="10" step="0.1">
</label>
<label>
Simulated throughput (MB/s):
<input type="number" id="throughputMB" value="10" min="1" max="100" step="1">
</label>
<label>
Chunk size (KB):
<input type="number" id="chunkKB" value="64" min="1" max="1024" step="1">
</label>
</div>
<div class="methods">
<!-- Method 1: In-memory array -->
<div class="method">
<h3>1. In-Memory Array</h3>
<p>Current approach: Push Uint8Array chunks to a JavaScript array. Simple but memory-heavy.</p>
<button onclick="runBenchmark('memory')">Run Test</button>
<button onclick="stopBenchmark('memory')">Stop</button>
<button onclick="downloadData_('memory')" id="download-memory" disabled>Download</button>
<button onclick="clearData('memory')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-memory"></div></div>
<div class="status" id="status-memory">Ready</div>
</div>
<!-- Method 2: Pre-allocated ArrayBuffer -->
<div class="method">
<h3>2. Pre-allocated Buffer</h3>
<p>Allocate a large ArrayBuffer upfront and write into it. Avoids repeated allocations.</p>
<button onclick="runBenchmark('preallocated')">Run Test</button>
<button onclick="stopBenchmark('preallocated')">Stop</button>
<button onclick="downloadData_('preallocated')" id="download-preallocated" disabled>Download</button>
<button onclick="clearData('preallocated')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-preallocated"></div></div>
<div class="status" id="status-preallocated">Ready</div>
</div>
<!-- Method 3: Chunked arrays -->
<div class="method">
<h3>3. Chunked Arrays (64MB blocks)</h3>
<p>Store data in fixed-size 64MB ArrayBuffers. Balances memory efficiency with GC pressure.</p>
<button onclick="runBenchmark('chunked')">Run Test</button>
<button onclick="stopBenchmark('chunked')">Stop</button>
<button onclick="downloadData_('chunked')" id="download-chunked" disabled>Download</button>
<button onclick="clearData('chunked')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-chunked"></div></div>
<div class="status" id="status-chunked">Ready</div>
</div>
<!-- Method 4: File System Access API -->
<div class="method">
<h3>4. File Stream (OPFS)</h3>
<p>Stream to Origin Private File System. Keeps memory low but requires async writes.</p>
<button onclick="runBenchmark('opfs')">Run Test</button>
<button onclick="stopBenchmark('opfs')">Stop</button>
<button onclick="downloadData_('opfs')" id="download-opfs" disabled>Download</button>
<button onclick="clearData('opfs')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-opfs"></div></div>
<div class="status" id="status-opfs">Ready</div>
</div>
<!-- Method 5: IndexedDB -->
<div class="method">
<h3>5. IndexedDB Chunks</h3>
<p>Store chunks in IndexedDB. Persistent storage with automatic memory management.</p>
<button onclick="runBenchmark('indexeddb')">Run Test</button>
<button onclick="stopBenchmark('indexeddb')">Stop</button>
<button onclick="downloadData_('indexeddb')" id="download-indexeddb" disabled>Download</button>
<button onclick="clearData('indexeddb')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-indexeddb"></div></div>
<div class="status" id="status-indexeddb">Ready</div>
</div>
<!-- Method 6: Blob accumulation -->
<div class="method">
<h3>6. Blob Accumulation</h3>
<p>Periodically merge chunks into Blobs. Leverages browser's blob storage.</p>
<button onclick="runBenchmark('blob')">Run Test</button>
<button onclick="stopBenchmark('blob')">Stop</button>
<button onclick="downloadData_('blob')" id="download-blob" disabled>Download</button>
<button onclick="clearData('blob')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-blob"></div></div>
<div class="status" id="status-blob">Ready</div>
</div>
</div>
<div id="results">
<h3>Results</h3>
<table id="resultsTable">
<thead>
<tr>
<th>Method</th>
<th>Status</th>
<th>Data Written</th>
<th>Time</th>
<th>Throughput</th>
<th>Peak Memory</th>
</tr>
</thead>
<tbody id="resultsBody">
</tbody>
</table>
</div>
<script>
const benchmarks = {};
const results = [];
const downloadData = {}; // Store data for download after each test
function getConfig() {
return {
targetBytes: parseFloat(document.getElementById('targetGB').value) * 1024 * 1024 * 1024,
throughputBytesPerSec: parseFloat(document.getElementById('throughputMB').value) * 1024 * 1024,
chunkSize: parseFloat(document.getElementById('chunkKB').value) * 1024
};
}
function updateStatus(method, text) {
document.getElementById(`status-${method}`).textContent = text;
}
function updateProgress(method, percent) {
document.getElementById(`progress-${method}`).style.width = `${percent}%`;
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
return bytes + ' B';
}
function getMemoryUsage() {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return null;
}
function addResult(method, status, bytesWritten, timeMs, peakMemory) {
const throughput = timeMs > 0 ? (bytesWritten / (timeMs / 1000)) : 0;
const row = document.createElement('tr');
row.innerHTML = `
<td>${method}</td>
<td class="${status === 'Success' ? 'success' : 'failure'}">${status}</td>
<td>${formatBytes(bytesWritten)}</td>
<td>${(timeMs / 1000).toFixed(2)}s</td>
<td>${formatBytes(throughput)}/s</td>
<td>${peakMemory ? formatBytes(peakMemory) : 'N/A'}</td>
`;
document.getElementById('resultsBody').appendChild(row);
}
function stopBenchmark(method) {
if (benchmarks[method]) {
benchmarks[method].stopped = true;
}
}
async function clearData(method) {
const data = downloadData[method];
if (data) {
// Special cleanup for persistent storage
if (data.type === 'opfs') {
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry('benchmark.bin');
} catch (e) { /* ignore */ }
} else if (data.type === 'indexeddb') {
await new Promise((resolve) => {
const req = indexedDB.deleteDatabase(data.dbName);
req.onsuccess = resolve;
req.onerror = resolve;
});
}
}
delete downloadData[method];
disableDownload(method);
updateStatus(method, 'Data cleared');
updateProgress(method, 0);
}
function enableDownload(method) {
document.getElementById(`download-${method}`).disabled = false;
}
function disableDownload(method) {
document.getElementById(`download-${method}`).disabled = true;
}
async function downloadData_(method) {
const data = downloadData[method];
if (!data) {
alert('No data available for download');
return;
}
updateStatus(method, 'Preparing download...');
try {
let blob;
const startTime = performance.now();
switch (data.type) {
case 'array':
// Array of Uint8Arrays
updateStatus(method, `Creating blob from ${data.chunks.length} chunks...`);
blob = new Blob(data.chunks, { type: 'application/octet-stream' });
break;
case 'buffer':
// Single ArrayBuffer
updateStatus(method, `Creating blob from buffer (${formatBytes(data.buffer.byteLength)})...`);
blob = new Blob([data.buffer], { type: 'application/octet-stream' });
break;
case 'chunked':
// Array of ArrayBuffers (blocks)
updateStatus(method, `Creating blob from ${data.blocks.length} blocks...`);
blob = new Blob(data.blocks, { type: 'application/octet-stream' });
break;
case 'opfs':
// Read from OPFS
updateStatus(method, 'Reading from OPFS...');
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('benchmark.bin');
const file = await fileHandle.getFile();
blob = file;
break;
case 'indexeddb':
// Read from IndexedDB
updateStatus(method, 'Reading from IndexedDB...');
blob = await readIndexedDBAsBlob(data.dbName, data.storeName);
break;
case 'blob':
// Already a blob
blob = data.blob;
break;
default:
throw new Error('Unknown data type');
}
const prepTime = performance.now() - startTime;
updateStatus(method, `Download ready (prep took ${(prepTime/1000).toFixed(2)}s)\nSize: ${formatBytes(blob.size)}`);
// Trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `benchmark-${method}-${formatBytes(blob.size).replace(' ', '')}.bin`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a delay to ensure download starts
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (e) {
updateStatus(method, `Download error: ${e.message}`);
}
}
async function readIndexedDBAsBlob(dbName, storeName) {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, 1);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
});
const chunks = await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
db.close();
return new Blob(chunks, { type: 'application/octet-stream' });
}
async function runBenchmark(method) {
const config = getConfig();
const state = {
stopped: false,
bytesWritten: 0,
startTime: performance.now(),
peakMemory: getMemoryUsage() || 0
};
benchmarks[method] = state;
updateStatus(method, 'Starting...');
updateProgress(method, 0);
// Generate a chunk of random-ish data (simulating VNC frame data)
const chunk = new Uint8Array(config.chunkSize);
for (let i = 0; i < chunk.length; i++) {
chunk[i] = Math.floor(Math.random() * 256);
}
try {
switch (method) {
case 'memory':
await benchmarkMemory(state, config, chunk);
break;
case 'preallocated':
await benchmarkPreallocated(state, config, chunk);
break;
case 'chunked':
await benchmarkChunked(state, config, chunk);
break;
case 'opfs':
await benchmarkOPFS(state, config, chunk);
break;
case 'indexeddb':
await benchmarkIndexedDB(state, config, chunk);
break;
case 'blob':
await benchmarkBlob(state, config, chunk);
break;
}
const elapsed = performance.now() - state.startTime;
if (state.stopped) {
addResult(method, 'Stopped', state.bytesWritten, elapsed, state.peakMemory);
} else {
addResult(method, 'Success', state.bytesWritten, elapsed, state.peakMemory);
}
updateStatus(method, `Complete: ${formatBytes(state.bytesWritten)} in ${(elapsed/1000).toFixed(2)}s`);
} catch (e) {
const elapsed = performance.now() - state.startTime;
addResult(method, `Failed: ${e.message}`, state.bytesWritten, elapsed, state.peakMemory);
updateStatus(method, `Error: ${e.message}`);
}
updateProgress(method, 100);
}
// Method 1: Simple in-memory array
async function benchmarkMemory(state, config, chunk) {
disableDownload('memory');
const frames = [];
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
frames.push(chunk.slice()); // Copy the chunk
state.bytesWritten += chunk.length;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('memory', progress);
updateStatus('memory', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}\nFrames: ${frames.length}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
// Yield to browser
await new Promise(r => setTimeout(r, 0));
}
}
// Store for download
downloadData['memory'] = { type: 'array', chunks: frames };
enableDownload('memory');
}
// Method 2: Pre-allocated ArrayBuffer
async function benchmarkPreallocated(state, config, chunk) {
disableDownload('preallocated');
updateStatus('preallocated', 'Allocating buffer...');
// Try to allocate the full buffer
let buffer;
try {
buffer = new ArrayBuffer(config.targetBytes);
} catch (e) {
throw new Error(`Failed to allocate ${formatBytes(config.targetBytes)}: ${e.message}`);
}
const view = new Uint8Array(buffer);
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
let offset = 0;
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
const remaining = config.targetBytes - offset;
const writeSize = Math.min(chunk.length, remaining);
view.set(chunk.subarray(0, writeSize), offset);
offset += writeSize;
state.bytesWritten = offset;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('preallocated', progress);
updateStatus('preallocated', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
await new Promise(r => setTimeout(r, 0));
}
}
// Store for download (trim to actual bytes written if stopped early)
downloadData['preallocated'] = { type: 'buffer', buffer: buffer.slice(0, state.bytesWritten) };
enableDownload('preallocated');
}
// Method 3: Chunked arrays (64MB blocks)
async function benchmarkChunked(state, config, chunk) {
disableDownload('chunked');
const BLOCK_SIZE = 64 * 1024 * 1024; // 64MB blocks
const blocks = [];
let currentBlock = new Uint8Array(BLOCK_SIZE);
let blockOffset = 0;
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
const remaining = BLOCK_SIZE - blockOffset;
if (remaining >= chunk.length) {
currentBlock.set(chunk, blockOffset);
blockOffset += chunk.length;
} else {
// Fill current block and start new one
currentBlock.set(chunk.subarray(0, remaining), blockOffset);
blocks.push(currentBlock);
currentBlock = new Uint8Array(BLOCK_SIZE);
blockOffset = chunk.length - remaining;
currentBlock.set(chunk.subarray(remaining), 0);
}
state.bytesWritten += chunk.length;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('chunked', progress);
updateStatus('chunked', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}\nBlocks: ${blocks.length + 1}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
await new Promise(r => setTimeout(r, 0));
}
}
// Add final partial block (trimmed to actual size)
if (blockOffset > 0) {
blocks.push(currentBlock.slice(0, blockOffset));
}
// Store for download
downloadData['chunked'] = { type: 'chunked', blocks: blocks };
enableDownload('chunked');
}
// Method 4: Origin Private File System
async function benchmarkOPFS(state, config, chunk) {
disableDownload('opfs');
if (!navigator.storage || !navigator.storage.getDirectory) {
throw new Error('OPFS not supported in this browser');
}
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('benchmark.bin', { create: true });
const writable = await fileHandle.createWritable();
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
await writable.write(chunk);
state.bytesWritten += chunk.length;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('opfs', progress);
updateStatus('opfs', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
}
}
await writable.close();
// Keep file for download (don't delete)
downloadData['opfs'] = { type: 'opfs' };
enableDownload('opfs');
}
// Method 5: IndexedDB
async function benchmarkIndexedDB(state, config, chunk) {
disableDownload('indexeddb');
const dbName = 'benchmark_db';
const storeName = 'chunks';
// Delete existing database
await new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = resolve;
req.onerror = resolve; // Ignore errors
});
// Create database
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, 1);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = (e) => {
e.target.result.createObjectStore(storeName, { autoIncrement: true });
};
});
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
const BATCH_SIZE = 100; // Write in batches for better performance
let batch = [];
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
batch.push(chunk.slice());
state.bytesWritten += chunk.length;
if (batch.length >= BATCH_SIZE || i === chunksNeeded - 1) {
// Write batch to IndexedDB
await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
for (const item of batch) {
store.add(item);
}
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
batch = [];
}
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('indexeddb', progress);
updateStatus('indexeddb', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
}
}
db.close();
// Keep database for download (don't delete)
downloadData['indexeddb'] = { type: 'indexeddb', dbName, storeName };
enableDownload('indexeddb');
}
// Method 6: Blob accumulation
async function benchmarkBlob(state, config, chunk) {
disableDownload('blob');
const MERGE_THRESHOLD = 10 * 1024 * 1024; // Merge every 10MB
let pendingChunks = [];
let pendingSize = 0;
let blobs = [];
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
pendingChunks.push(chunk.slice());
pendingSize += chunk.length;
state.bytesWritten += chunk.length;
// Periodically merge into a blob
if (pendingSize >= MERGE_THRESHOLD) {
const blob = new Blob(pendingChunks, { type: 'application/octet-stream' });
blobs.push(blob);
pendingChunks = [];
pendingSize = 0;
}
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('blob', progress);
updateStatus('blob', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}\nBlobs: ${blobs.length}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
await new Promise(r => setTimeout(r, 0));
}
}
// Final merge
if (pendingChunks.length > 0) {
blobs.push(new Blob(pendingChunks, { type: 'application/octet-stream' }));
}
// Create final blob (for download capability)
const finalBlob = new Blob(blobs, { type: 'application/octet-stream' });
updateStatus('blob', `Complete: ${formatBytes(state.bytesWritten)}\nFinal blob: ${formatBytes(finalBlob.size)}`);
// Store for download
downloadData['blob'] = { type: 'blob', blob: finalBlob };
enableDownload('blob');
}
</script>
</body>
</html>

View File

@ -116,6 +116,7 @@ class IterationPlayer {
this.onfinish = () => {}; this.onfinish = () => {};
this.oniterationfinish = () => {}; this.oniterationfinish = () => {};
this.rfbdisconnected = () => {}; this.rfbdisconnected = () => {};
this.onclientevent = () => {};
} }
start(realtime) { start(realtime) {
@ -130,6 +131,7 @@ class IterationPlayer {
_nextIteration() { _nextIteration() {
const player = new RecordingPlayer(this._frames, this._disconnected.bind(this)); const player = new RecordingPlayer(this._frames, this._disconnected.bind(this));
player.onfinish = this._iterationFinish.bind(this); player.onfinish = this._iterationFinish.bind(this);
player.onclientevent = this.onclientevent;
if (this._state !== 'running') { return; } if (this._state !== 'running') { return; }
@ -212,6 +214,28 @@ function start() {
document.getElementById('startButton').disabled = false; document.getElementById('startButton').disabled = false;
document.getElementById('startButton').value = "Start"; document.getElementById('startButton').value = "Start";
}; };
// Log client input events (skip mouse moves and framebuffer requests to avoid spam)
player.onclientevent = (timestamp, event) => {
const timeStr = (timestamp / 1000).toFixed(2) + 's';
switch (event.type) {
case 'KeyEvent':
const action = event.down ? 'down' : 'up';
message(`[${timeStr}] Key ${action}: ${event.keyName}`);
break;
case 'PointerEvent':
// Only log button down/up, not moves
if (!event.isMove && event.events.length > 0) {
for (const e of event.events) {
message(`[${timeStr}] Mouse ${e.button} ${e.action} at (${event.x}, ${event.y})`);
}
}
break;
case 'ClientCutText':
message(`[${timeStr}] Clipboard: "${event.text}"`);
break;
// Skip SetPixelFormat, SetEncodings, FramebufferUpdateRequest - too noisy
}
};
player.start(realtime); player.start(realtime);
} }

View File

@ -77,6 +77,119 @@ export default class RecordingPlayer {
this._running = false; this._running = false;
this.onfinish = () => {}; this.onfinish = () => {};
this.onclientevent = () => {}; // Callback for client events
this._lastButtonMask = 0; // Track previous button state for down/up detection
}
// Decode client-to-server RFB message
_decodeClientMessage(data) {
if (data.length < 1) return null;
const msgType = data[0];
switch (msgType) {
case 0: // SetPixelFormat
return { type: 'SetPixelFormat' };
case 2: // SetEncodings
if (data.length >= 4) {
const numEncodings = (data[2] << 8) | data[3];
return { type: 'SetEncodings', count: numEncodings };
}
return { type: 'SetEncodings' };
case 3: // FramebufferUpdateRequest
if (data.length >= 10) {
const incremental = data[1];
const x = (data[2] << 8) | data[3];
const y = (data[4] << 8) | data[5];
const width = (data[6] << 8) | data[7];
const height = (data[8] << 8) | data[9];
return {
type: 'FramebufferUpdateRequest',
incremental: incremental === 1,
x, y, width, height
};
}
return { type: 'FramebufferUpdateRequest' };
case 4: // KeyEvent
if (data.length >= 8) {
const down = data[1] === 1;
const keysym = (data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7];
// Try to convert keysym to character
let keyName = '0x' + keysym.toString(16);
if (keysym >= 0x20 && keysym <= 0x7e) {
keyName = String.fromCharCode(keysym);
} else if (keysym >= 0xff00) {
// Special keys
const specialKeys = {
0xff08: 'BackSpace', 0xff09: 'Tab', 0xff0d: 'Return',
0xff1b: 'Escape', 0xff50: 'Home', 0xff51: 'Left',
0xff52: 'Up', 0xff53: 'Right', 0xff54: 'Down',
0xff55: 'PageUp', 0xff56: 'PageDown', 0xff57: 'End',
0xff63: 'Insert', 0xffff: 'Delete',
0xffe1: 'Shift_L', 0xffe2: 'Shift_R',
0xffe3: 'Control_L', 0xffe4: 'Control_R',
0xffe9: 'Alt_L', 0xffea: 'Alt_R',
0xffeb: 'Super_L', 0xffec: 'Super_R',
};
keyName = specialKeys[keysym] || keyName;
}
return { type: 'KeyEvent', down, keysym, keyName };
}
return { type: 'KeyEvent' };
case 5: // PointerEvent
if (data.length >= 6) {
const buttonMask = data[1];
const x = (data[2] << 8) | data[3];
const y = (data[4] << 8) | data[5];
// Detect button changes by comparing with previous state
const prevMask = this._lastButtonMask;
const pressed = buttonMask & ~prevMask; // Bits that are now 1 but were 0
const released = prevMask & ~buttonMask; // Bits that are now 0 but were 1
this._lastButtonMask = buttonMask;
const events = [];
// Check each button for down/up
const buttonNames = ['left', 'middle', 'right', 'scrollUp', 'scrollDown'];
for (let i = 0; i < 5; i++) {
const bit = 1 << i;
if (pressed & bit) {
events.push({ button: buttonNames[i], action: 'down' });
}
if (released & bit) {
events.push({ button: buttonNames[i], action: 'up' });
}
}
return {
type: 'PointerEvent',
x, y,
buttonMask,
events: events, // Array of {button, action} for changes
isMove: events.length === 0
};
}
return { type: 'PointerEvent' };
case 6: // ClientCutText
if (data.length >= 8) {
const length = (data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7];
let text = '';
for (let i = 8; i < Math.min(8 + length, data.length); i++) {
text += String.fromCharCode(data[i]);
}
return { type: 'ClientCutText', text: text.substring(0, 50) + (length > 50 ? '...' : '') };
}
return { type: 'ClientCutText' };
default:
return { type: 'Unknown', msgType };
}
} }
run(realtime, trafficManagement) { run(realtime, trafficManagement) {
@ -105,8 +218,13 @@ export default class RecordingPlayer {
let frame = this._frames[this._frameIndex]; let frame = this._frames[this._frameIndex];
// skip send frames // Process and report client frames, then skip them
while (this._frameIndex < this._frameLength && frame.fromClient) { while (this._frameIndex < this._frameLength && frame.fromClient) {
// Decode and report the client event
const decoded = this._decodeClientMessage(frame.data);
if (decoded) {
this.onclientevent(frame.timestamp, decoded);
}
this._frameIndex++; this._frameIndex++;
frame = this._frames[this._frameIndex]; frame = this._frames[this._frameIndex];
} }

View File

@ -34,12 +34,15 @@ usage() {
echo " --heartbeat SEC send a ping to the client every SEC seconds" echo " --heartbeat SEC send a ping to the client every SEC seconds"
echo " --timeout SEC after SEC seconds exit when not connected" echo " --timeout SEC after SEC seconds exit when not connected"
echo " --idle-timeout SEC server exits after SEC seconds if there are no" echo " --idle-timeout SEC server exits after SEC seconds if there are no"
echo " active connections"
echo " " echo " "
echo " --web-auth enable authentication" echo " --web-auth enable authentication"
echo " --auth-plugin CLASS authentication plugin to use" echo " --auth-plugin CLASS authentication plugin to use"
echo " --auth-source ARG plugin configuration" echo " --auth-source ARG plugin configuration"
echo " " echo " "
echo " active connections" 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 " " echo " "
exit 2 exit 2
} }
@ -65,6 +68,9 @@ WEBAUTH_ARG=""
AUTHPLUGIN_ARG="" AUTHPLUGIN_ARG=""
AUTHSOURCE_ARG="" AUTHSOURCE_ARG=""
FILEONLY_ARG="" FILEONLY_ARG=""
VNC_PASSWORD=""
AUTOCONNECT=""
AUTORECORD=""
die() { die() {
@ -103,6 +109,9 @@ while [ "$*" ]; do
--web-auth) WEBAUTH_ARG="--web-auth" ;; --web-auth) WEBAUTH_ARG="--web-auth" ;;
--auth-plugin) AUTHPLUGIN_ARG="--auth-plugin ${OPTARG}"; shift ;; --auth-plugin) AUTHPLUGIN_ARG="--auth-plugin ${OPTARG}"; shift ;;
--auth-source) AUTHSOURCE_ARG="--auth-source ${OPTARG}"; shift ;; --auth-source) AUTHSOURCE_ARG="--auth-source ${OPTARG}"; shift ;;
--password) VNC_PASSWORD="${OPTARG}"; shift ;;
--autoconnect) AUTOCONNECT="1" ;;
--autorecord) AUTORECORD="1" ;;
-h|--help) usage ;; -h|--help) usage ;;
-*) usage "Unknown chrooter option: ${param}" ;; -*) usage "Unknown chrooter option: ${param}" ;;
*) break ;; *) break ;;
@ -223,10 +232,14 @@ if [ -z "$HOST" ]; then
fi fi
echo -e "\n\nNavigate to this URL:\n" echo -e "\n\nNavigate to this URL:\n"
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 [ "x$SSLONLY" == "x" ]; then if [ "x$SSLONLY" == "x" ]; then
echo -e " http://${HOST}:${PORT}/vnc.html?host=${HOST}&port=${PORT}\n" echo -e " http://${HOST}:${PORT}/vnc.html?${URL_PARAMS}\n"
else else
echo -e " https://${HOST}:${PORT}/vnc.html?host=${HOST}&port=${PORT}\n" echo -e " https://${HOST}:${PORT}/vnc.html?${URL_PARAMS}\n"
fi fi
echo -e "Press Ctrl-C to exit\n\n" echo -e "Press Ctrl-C to exit\n\n"

View File

@ -190,6 +190,24 @@
</div> </div>
</div> </div>
<!-- Record session -->
<input type="image" alt="Record" src="app/images/record.svg"
id="noVNC_record_button" class="noVNC_button"
title="Record session">
<div class="noVNC_vcenter">
<div id="noVNC_record" class="noVNC_panel">
<div class="noVNC_heading">
<img alt="" src="app/images/record.svg"> Recording
</div>
<p id="noVNC_record_status">Not recording</p>
<p id="noVNC_record_stats"></p>
<p id="noVNC_record_storage"></p>
<input type="button" id="noVNC_record_start_button" value="Start Recording">
<input type="button" id="noVNC_record_stop_button" value="Stop Recording" disabled>
<input type="button" id="noVNC_record_download_button" value="Download" disabled>
</div>
</div>
<!-- Toggle fullscreen --> <!-- Toggle fullscreen -->
<input type="image" alt="Full screen" src="app/images/fullscreen.svg" <input type="image" alt="Full screen" src="app/images/fullscreen.svg"
id="noVNC_fullscreen_button" class="noVNC_button noVNC_hidden" id="noVNC_fullscreen_button" class="noVNC_button noVNC_hidden"