290 lines
9.8 KiB
JavaScript
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"});
|
|
}
|
|
}
|