Add session recorder to noVNC
This commit is contained in:
parent
d5b928f8b6
commit
e7e7d159ae
|
|
@ -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 |
|
|
@ -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
449
app/ui.js
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
18
vnc.html
18
vnc.html
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue