noVNC/tests/playback.js

290 lines
9.8 KiB
JavaScript

/*
* noVNC: HTML5 VNC client
* Copyright (C) 2018 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*/
import RFB from '../core/rfb.js';
import * as Log from '../core/util/logging.js';
// Immediate polyfill
if (window.setImmediate === undefined) {
let _immediateIdCounter = 1;
const _immediateFuncs = {};
window.setImmediate = (func) => {
const index = _immediateIdCounter++;
_immediateFuncs[index] = func;
window.postMessage("noVNC immediate trigger:" + index, "*");
return index;
};
window.clearImmediate = (id) => {
_immediateFuncs[id];
};
window.addEventListener("message", (event) => {
if ((typeof event.data !== "string") ||
(event.data.indexOf("noVNC immediate trigger:") !== 0)) {
return;
}
const index = event.data.slice("noVNC immediate trigger:".length);
const callback = _immediateFuncs[index];
if (callback === undefined) {
return;
}
delete _immediateFuncs[index];
callback();
});
}
class FakeWebSocket {
constructor() {
this.binaryType = "arraybuffer";
this.protocol = "";
this.readyState = "open";
this.onerror = () => {};
this.onmessage = () => {};
this.onopen = () => {};
}
send() {
}
close() {
}
}
export default class RecordingPlayer {
constructor(frames, disconnected) {
this._frames = frames;
this._disconnected = disconnected;
this._rfb = undefined;
this._frameLength = this._frames.length;
this._frameIndex = 0;
this._startTime = undefined;
this._realtime = true;
this._trafficManagement = true;
this._running = false;
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) {
// initialize a new RFB
this._ws = new FakeWebSocket();
this._rfb = new RFB(document.getElementById('VNC_screen'), this._ws);
this._rfb.viewOnly = true;
this._rfb.addEventListener("disconnect",
this._handleDisconnect.bind(this));
this._rfb.addEventListener("credentialsrequired",
this._handleCredentials.bind(this));
// reset the frame index and timer
this._frameIndex = 0;
this._startTime = (new Date()).getTime();
this._realtime = realtime;
this._trafficManagement = (trafficManagement === undefined) ? !realtime : trafficManagement;
this._running = true;
this._queueNextPacket();
}
_queueNextPacket() {
if (!this._running) { return; }
let frame = this._frames[this._frameIndex];
// Process and report client frames, then skip them
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++;
frame = this._frames[this._frameIndex];
}
if (this._frameIndex >= this._frameLength) {
Log.Debug('Finished, no more frames');
this._finish();
return;
}
if (this._realtime) {
const toffset = (new Date()).getTime() - this._startTime;
let delay = frame.timestamp - toffset;
if (delay < 1) delay = 1;
setTimeout(this._doPacket.bind(this), delay);
} else {
setImmediate(this._doPacket.bind(this));
}
}
_doPacket() {
// Avoid having excessive queue buildup in non-realtime mode
if (this._trafficManagement && this._rfb._flushing) {
this._rfb.flush()
.then(() => {
this._doPacket();
});
return;
}
const frame = this._frames[this._frameIndex];
this._ws.onmessage({'data': frame.data});
this._frameIndex++;
this._queueNextPacket();
}
_finish() {
if (this._rfb._display.pending()) {
this._rfb._display.flush()
.then(() => { this._finish(); });
} else {
this._running = false;
this._ws.onclose({code: 1000, reason: ""});
delete this._rfb;
this.onfinish((new Date()).getTime() - this._startTime);
}
}
_handleDisconnect(evt) {
this._running = false;
this._disconnected(evt.detail.clean, this._frameIndex);
}
_handleCredentials(evt) {
this._rfb.sendCredentials({"username": "Foo",
"password": "Bar",
"target": "Baz"});
}
}