WIP - multi monitor refactor

This commit is contained in:
mattmcclaskey 2023-09-08 13:12:28 -04:00
parent 68135beedd
commit 419a3a70e9
No known key found for this signature in database
8 changed files with 640 additions and 229 deletions

View File

@ -24,7 +24,7 @@ For support with KasmVNC, post on the [KasmVNC Project](https://github.com/kasmt
- Automatic mixing of webp and jpeg based on CPU availability on server
- WebRTC UDP Transit
- Lossless QOI Image format for Local LAN
- [Dynamic jpeg/webp image coompression](https://github.com/kasmtech/KasmVNC/wiki/Video-Rendering-Options#dynamic-image-quality) quality settings based on screen change rates
- [Dynamic jpeg/webp image compression](https://github.com/kasmtech/KasmVNC/wiki/Video-Rendering-Options#dynamic-image-quality) quality settings based on screen change rates
- Seemless clipboard support (on Chromium based browsers)
- Binary clipboard support for text, images, and formatted text (on Chromium based browsers)
- Allow client to set/change most configuration settings

105
app/ui.js
View File

@ -31,8 +31,8 @@ window.updateSetting = (name, value) => {
}
}
import "core-js/stable";
import "regenerator-runtime/runtime";
//import "core-js/stable";
//import "regenerator-runtime/runtime";
import * as Log from '../core/util/logging.js';
import _, { l10n } from './localization.js';
import { isTouchDevice, isSafari, hasScrollbarGutter, dragThreshold, supportsBinaryClipboard, isFirefox, isWindows, isIOS, supportsPointerLock }
@ -69,6 +69,8 @@ const UI = {
reconnectCallback: null,
reconnectPassword: null,
supportsBroadcastChannel: (typeof BroadcastChannel !== "undefined"),
prime() {
return WebUtil.initSettings().then(() => {
if (document.readyState === "interactive" || document.readyState === "complete") {
@ -128,14 +130,13 @@ const UI = {
UI.addExtraKeysHandlers();
UI.addGamingHandlers();
UI.addMachineHandlers();
UI.addConnectionControlHandlers();
UI.addClipboardHandlers();
UI.addSettingsHandlers();
UI.addMultiMonitorAddHandler();
document.getElementById("noVNC_status")
.addEventListener('click', UI.hideStatus);
UI.openControlbar();
//
UI.updateVisualState('init');
@ -472,21 +473,6 @@ const UI = {
.addEventListener('click', () => UI.rfb.machineReset());
},
addConnectionControlHandlers() {
UI.addClickHandle('noVNC_disconnect_button', UI.disconnect);
var connect_btn_el = document.getElementById("noVNC_connect_button");
if (typeof(connect_btn_el) != 'undefined' && connect_btn_el != null)
{
connect_btn_el.addEventListener('click', UI.connect);
}
document.getElementById("noVNC_cancel_reconnect_button")
.addEventListener('click', UI.cancelReconnect);
document.getElementById("noVNC_credentials_button")
.addEventListener('click', UI.setCredentials);
},
addClipboardHandlers() {
UI.addClickHandle('noVNC_clipboard_button', UI.toggleClipboardPanel);
@ -588,6 +574,13 @@ const UI = {
window.addEventListener('msfullscreenchange', UI.updateFullscreenButton);
},
addMultiMonitorAddHandler() {
if (UI.supportsBroadcastChannel) {
UI.showControlInput("noVNC_addmonitor_button");
UI.addClickHandle('noVNC_addmonitor_button', UI.addSecondaryMonitor);
}
},
/* ------^-------
* /EVENT HANDLERS
* ==============
@ -676,8 +669,6 @@ const UI = {
// State change closes dialogs as they may not be relevant
// anymore
UI.closeAllPanels();
document.getElementById('noVNC_credentials_dlg')
.classList.remove('noVNC_open');
},
showStats() {
@ -1380,9 +1371,12 @@ const UI = {
UI.rfb = new RFB(document.getElementById('noVNC_container'),
document.getElementById('noVNC_keyboardinput'),
url,
{ shared: UI.getSetting('shared'),
{
shared: UI.getSetting('shared'),
repeaterID: UI.getSetting('repeaterID'),
credentials: { password: password } });
credentials: { password: password }
},
true );
UI.rfb.addEventListener("connect", UI.connectFinished);
UI.rfb.addEventListener("disconnect", UI.disconnectFinished);
UI.rfb.addEventListener("credentialsrequired", UI.credentials);
@ -1746,57 +1740,6 @@ const UI = {
parent.postMessage({ action: 'clipboardrx', value: event.detail.text}, '*' ); //TODO fix star
},
/* ------^-------
* /CONNECTION
* ==============
* PASSWORD
* ------v------*/
credentials(e) {
// FIXME: handle more types
document.getElementById("noVNC_username_block").classList.remove("noVNC_hidden");
document.getElementById("noVNC_password_block").classList.remove("noVNC_hidden");
let inputFocus = "none";
if (e.detail.types.indexOf("username") === -1) {
document.getElementById("noVNC_username_block").classList.add("noVNC_hidden");
} else {
inputFocus = inputFocus === "none" ? "noVNC_username_input" : inputFocus;
}
if (e.detail.types.indexOf("password") === -1) {
document.getElementById("noVNC_password_block").classList.add("noVNC_hidden");
} else {
inputFocus = inputFocus === "none" ? "noVNC_password_input" : inputFocus;
}
document.getElementById('noVNC_credentials_dlg')
.classList.add('noVNC_open');
setTimeout(() => document
.getElementById(inputFocus).focus(), 100);
Log.Warn("Server asked for credentials");
UI.showStatus(_("Credentials are required"), "warning");
},
setCredentials(e) {
// Prevent actually submitting the form
e.preventDefault();
let inputElemUsername = document.getElementById('noVNC_username_input');
const username = inputElemUsername.value;
let inputElemPassword = document.getElementById('noVNC_password_input');
const password = inputElemPassword.value;
// Clear the input after reading the password
inputElemPassword.value = "";
UI.rfb.sendCredentials({ username: username, password: password });
UI.reconnectPassword = password;
document.getElementById('noVNC_credentials_dlg')
.classList.remove('noVNC_open');
},
/* ------^-------
* /PASSWORD
* ==============
@ -1867,6 +1810,20 @@ const UI = {
UI.rfb.enableHiDpi = UI.getSetting('enable_hidpi');
},
/* ------^-------
* /MULTI-MONITOR SUPPORT
* ==============*/
addSecondaryMonitor() {
let new_display_path = window.location.pathname.replace(/[^/]*$/, '')
let new_display_url = `${window.location.protocol}//${window.location.host}${new_display_path}screen.html`;
Log.Debug(`Opening a secondary display ${new_display_url}`)
window.open(new_display_url);
},
/* ------^-------
* /RESIZE
* ==============

40
app/ui_screen.js Normal file
View File

@ -0,0 +1,40 @@
const UI = {
connected: false,
//Initial Loading of the UI
prime() {
},
//Render default UI
start() {
window.addEventListener("beforeunload", (e) => {
if (UI.rfb) {
UI.disconnect();
}
});
UI.addDefaultHandlers();
},
addDefaultHandlers() {
document.getElementById('noVNC_connect_button', UI.connect);
},
connect() {
},
disconnect() {
}
}
UI.prime();
export default UI;

View File

@ -11,6 +11,7 @@ import * as Log from './util/logging.js';
import Base64 from "./base64.js";
import { toSigned32bit } from './util/int.js';
import { isWindows } from './util/browser.js';
import { uuidv4 } from './util/strings.js'
export default class Display {
constructor(target) {
@ -56,7 +57,7 @@ export default class Display {
this._targetCtx = this._target.getContext('2d');
// the visible canvas viewport (i.e. what actually gets seen)
this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
//this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
Log.Debug("User Agent: " + navigator.userAgent);
@ -84,6 +85,21 @@ export default class Display {
this._clipViewport = false;
this._antiAliasing = 0;
this._fps = 0;
this._screens = [{
screenID: uuidv4(),
screenIndex: 0,
width: this._target.width, //client
height: this._target.height, //client
serverWidth: 0, //calculated
serverHeight: 0, //calculated
x: 0,
y: 0,
relativePosition: 0,
pixelRatio: window.devicePixelRatio,
containerHeight: this._target.parentNode.offsetHeight,
containerWidth: this._target.parentNode.offsetWidth,
channel: null
}];
// ===== EVENT HANDLERS =====
@ -97,6 +113,8 @@ export default class Display {
// ===== PROPERTIES =====
get screens() { return this._screens; }
get antiAliasing() { return this._antiAliasing; }
set antiAliasing(value) {
this._antiAliasing = value;
@ -112,8 +130,8 @@ export default class Display {
set clipViewport(viewport) {
this._clipViewport = viewport;
// May need to readjust the viewport dimensions
const vp = this._viewportLoc;
this.viewportChangeSize(vp.w, vp.h);
const vp = this._screens[0];
this.viewportChangeSize(vp.width, vp.height);
this.viewportChangePos(0, 0);
}
@ -136,18 +154,167 @@ export default class Display {
// ===== PUBLIC METHODS =====
getScreenSize(resolutionQuality, max_width, max_height, hiDpi, disableLimit) {
let data = {
screens: null,
serverWidth: 0,
serverHeight: 0,
clientWidth: 0,
clientHeight: 0
}
//recalculate primary display container size
this._screens[0].containerHeight = this._target.parentNode.offsetHeight;
this._screens[0].containerWidth = this._target.parentNode.offsetWidth;
//calculate server-side resolution of each screen
for (let i=0; i<this._screens.length; i++) {
let width = max_width || this._screens[i].containerWidth;
let height = max_height || this._screens[i].containerHeight;
let scale = 0;
//max the resolution of a single screen to 1280
if (width > 1280 && !disableLimit && resolutionQuality == 1) {
height = 1280 * (height/width); //keeping the aspect ratio of original resolution, shrink y to match x
width = 1280;
}
//hard coded 720p
else if (resolutionQuality == 0 && !disableLimit) {
width = 1280;
height = 720;
}
//force full resolution on a high DPI monitor where the OS is scaling
else if (hiDpi) {
width = width * this._screens[i].pixelRatio;
height = height * this._screens[i].pixelRatio;
scale = 1 / this._screens[i].pixelRatio;
}
//physically small device with high DPI
else if (this._antiAliasing === 0 && this._screens[i].pixelRatio > 1 && width < 1000 & width > 0) {
Log.Info('Device Pixel ratio: ' + this._screens[i].pixelRatio + ' Reported Resolution: ' + width + 'x' + height);
let targetDevicePixelRatio = 1.5;
if (this._screens[i].pixelRatio > 2) { targetDevicePixelRatio = 2; }
let scaledWidth = (width * this._screens[i].pixelRatio) * (1 / targetDevicePixelRatio);
let scaleRatio = scaledWidth / x;
width = width * scaleRatio;
height = height * scaleRatio;
scale = 1 / scaleRatio;
Log.Info('Small device with hDPI screen detected, auto scaling at ' + scaleRatio + ' to ' + width + 'x' + height);
}
this._screens[i].serverWidth = width;
this._screens[i].serverHeight = height;
//this logic will only support monitors laid out side by side
//TODO: two vertically stacked monitors would require light refactoring here
data.serverWidth += width;
data.serverHeight = Math.max(data.serverHeight, height);
data.clientWidth += this._screens[i].width;
data.clientHeight = Math.max(data.clientHeight, this._screens[i].height);
this._screens[i].scale = scale;
}
//calculate positions of monitors, this logic will only support two monitors side by side in either order
if (this._screens.length > 1) {
const primary_screen = this._screens[0];
const secondary_screen = this._screens[1];
switch (this._screens[1].relativePosition) {
case 0:
//primary screen is to left
total_width = secondary_screen.serverWidth + primary_screen.serverWidth;
total_height = Math.max(primary_screen.serverHeight, secondary_screen.serverHeight);
secondary_screen.x = primary_screen.serverWidth;
if (secondary_screen.serverHeight < primary_screen.serverHeight) {
if ((total_height - secondary_screen.serverHeight) > 1) {
secondary_screen.y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2)))
}
} else {
secondary_screen.y = 0;
if ((total_height - secondary_screen.serverHeight) > 1) {
this._screens[0].y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2)))
}
}
break;
case 2:
//primary screen is to right
total_width = primary_screen.serverWidth + secondary_screen.serverWidth;
total_height = Math.max(primary_screen.serverHeight, secondary_screen.serverHeight);
this._screens[0].x = secondary_screen.serverWidth;
if (secondary_screen.serverHeight < primary_screen.serverHeight) {
if ((total_height - secondary_screen.serverHeight) > 1) {
secondary_screen.y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2)))
}
} else {
secondary_screen.y = 0;
if ((total_height - secondary_screen.serverHeight) > 1) {
primary_screen.y = Math.abs(Math.round(((total_height - secondary_screen.serverHeight) / 2)))
}
}
break;
default:
//TODO: It would not be hard to support vertically stacked monitors
throw new Error("Unsupported screen orientation.");
}
}
data.screens = structuredClone(this._screens);
return data;
}
addScreen(screenID, width, height, relativePosition, pixelRatio, containerHeight, containerWidth) {
//currently only support one secondary screen
if (this._screens.length > 1) {
this._screens[1].channel.close();
this._screens.pop()
}
screenIdx = this.screens.length;
new_screen = {
screenID: screenID,
screenIndex: screenIdx,
width: width, //client
height: height, //client
serverWidth: 0, //calculated
serverHeight: 0, //calculated
x: 0,
y: 0,
relativePosition: relativePosition,
pixelRatio: pixelRatio,
containerHeight: containerHeight,
containerWidth: containerWidth,
channel: null,
scale: 0
}
new_screen.channel = new BroadcastChannel(`screen_${screenID}_channel`);
//new_screen.channel.message = this._handleSecondaryDisplayMessage().bind(this);
this._screens.push(new_screen);
new_screen.channel.postMessage({ eventType: "registered", screenIndex: screenIdx });
}
removeScreen(screenID) {
for (let i=1; i<this._screens.length; i++) {
if (this._screens[i].screenID == screenID) {
this._screens.splice(i, 1);
return true;
}
}
return false;
}
viewportChangePos(deltaX, deltaY) {
const vp = this._viewportLoc;
const vp = this._screens[0];
deltaX = Math.floor(deltaX);
deltaY = Math.floor(deltaY);
if (!this._clipViewport) {
deltaX = -vp.w; // clamped later of out of bounds
deltaY = -vp.h;
deltaX = -vp.width; // clamped later of out of bounds
deltaY = -vp.height;
}
const vx2 = vp.x + vp.w - 1;
const vy2 = vp.y + vp.h - 1;
const vx2 = vp.x + vp.width - 1;
const vy2 = vp.y + vp.height - 1;
// Position change
@ -192,10 +359,10 @@ export default class Display {
height = this._fbHeight;
}
const vp = this._viewportLoc;
if (vp.w !== width || vp.h !== height) {
vp.w = width;
vp.h = height;
const vp = this._screens[0];
if (vp.width !== width || vp.height !== height) {
vp.width = width;
vp.height = height;
const canvas = this._target;
canvas.width = width;
@ -213,14 +380,14 @@ export default class Display {
if (this._scale === 0) {
return 0;
}
return toSigned32bit(x / this._scale + this._viewportLoc.x);
return toSigned32bit(x / this._scale + this._screens[0].x);
}
absY(y) {
if (this._scale === 0) {
return 0;
}
return toSigned32bit(y / this._scale + this._viewportLoc.y);
return toSigned32bit(y / this._scale + this._screens[0].y);
}
resize(width, height) {
@ -253,8 +420,8 @@ export default class Display {
// Readjust the viewport as it may be incorrectly sized
// and positioned
const vp = this._viewportLoc;
this.viewportChangeSize(vp.w, vp.h);
const vp = this._screens[0];
this.viewportChangeSize(vp.width, vp.height);
this.viewportChangePos(0, 0);
}
@ -464,14 +631,14 @@ export default class Display {
} else if (scaleRatio === 0) {
const vp = this._viewportLoc;
const vp = this._screens[0];
const targetAspectRatio = containerWidth / containerHeight;
const fbAspectRatio = vp.w / vp.h;
const fbAspectRatio = vp.width / vp.height;
if (fbAspectRatio >= targetAspectRatio) {
scaleRatio = containerWidth / vp.w;
scaleRatio = containerWidth / vp.width;
} else {
scaleRatio = containerHeight / vp.h;
scaleRatio = containerHeight / vp.height;
}
}
@ -658,14 +825,14 @@ export default class Display {
_rescale(factor) {
this._scale = factor;
const vp = this._viewportLoc;
const vp = this._screens[0];
// NB(directxman12): If you set the width directly, or set the
// style width to a number, the canvas is cleared.
// However, if you set the style width to a string
// ('NNNpx'), the canvas is scaled without clearing.
const width = factor * vp.w + 'px';
const height = factor * vp.h + 'px';
const width = factor * vp.width + 'px';
const height = factor * vp.height + 'px';
if ((this._target.style.width !== width) ||
(this._target.style.height !== height)) {
@ -673,12 +840,12 @@ export default class Display {
this._target.style.height = height;
}
Log.Info('Pixel Ratio: ' + window.devicePixelRatio + ', VNC Scale: ' + factor + 'VNC Res: ' + vp.w + 'x' + vp.h);
Log.Info('Pixel Ratio: ' + window.devicePixelRatio + ', VNC Scale: ' + factor + 'VNC Res: ' + vp.width + 'x' + vp.height);
var pixR = Math.abs(Math.ceil(window.devicePixelRatio));
var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
if (this.antiAliasing === 2 || (this.antiAliasing === 0 && factor === 1 && this._target.style.imageRendering !== 'pixelated' && pixR === window.devicePixelRatio && vp.w > 0)) {
if (this.antiAliasing === 2 || (this.antiAliasing === 0 && factor === 1 && this._target.style.imageRendering !== 'pixelated' && pixR === window.devicePixelRatio && vp.width > 0)) {
this._target.style.imageRendering = ((!isFirefox) ? 'pixelated' : 'crisp-edges' );
Log.Debug('Smoothing disabled');
} else if (this.antiAliasing === 1 || (this.antiAliasing === 0 && factor !== 1 && this._target.style.imageRendering !== 'auto')) {

View File

@ -10,7 +10,7 @@
import { toUnsigned32bit, toSigned32bit } from './util/int.js';
import * as Log from './util/logging.js';
import { encodeUTF8, decodeUTF8 } from './util/strings.js';
import { encodeUTF8, decodeUTF8, uuidv4 } from './util/strings.js';
import { hashUInt8Array } from './util/int.js';
import { dragThreshold, supportsCursorURIs, isTouchDevice, isWindows, isMac, isIOS } from './util/browser.js';
import { clientToElement } from './util/element.js';
@ -76,7 +76,7 @@ const extendedClipboardActionNotify = 1 << 27;
const extendedClipboardActionProvide = 1 << 28;
export default class RFB extends EventTargetMixin {
constructor(target, touchInput, urlOrChannel, options) {
constructor(target, touchInput, urlOrChannel, options, isPrimaryDisplay) {
if (!target) {
throw new Error("Must specify target");
}
@ -101,6 +101,7 @@ export default class RFB extends EventTargetMixin {
this._shared = 'shared' in options ? !!options.shared : true;
this._repeaterID = options.repeaterID || '';
this._wsProtocols = options.wsProtocols || ['binary'];
this._isPrimaryDisplay = (isPrimaryDisplay !== false);
// Internal state
this._rfbConnectionState = '';
@ -122,7 +123,8 @@ export default class RFB extends EventTargetMixin {
this._supportsContinuousUpdates = false;
this._enabledContinuousUpdates = false;
this._supportsSetDesktopSize = false;
this._screenID = 0;
this._screenID = uuidv4();
this._screenIndex = 0;
this._screenFlags = 0;
this._qemuExtKeyEventSupported = false;
@ -212,6 +214,19 @@ export default class RFB extends EventTargetMixin {
this._gestureLastMagnitudeX = 0;
this._gestureLastMagnitudeY = 0;
// Secondary Displays
this._secondaryDisplays = {};
this._supportsBroadcastChannel = (typeof BroadcastChannel !== "undefined");
if (this._supportsBroadcastChannel) {
this._controlChannel = new BroadcastChannel("registrationChannel");
this._controlChannel.message = this._handleControlMessage.bind(this);
Log.Debug("Attached to registrationChannel for secondary displays.")
}
if (!this._isPrimaryDisplay) {
this._screenIndex = 2;
}
// Bound event handlers
this._eventHandlers = {
updateHiddenKeyboard: this._updateHiddenKeyboard.bind(this),
@ -283,68 +298,13 @@ export default class RFB extends EventTargetMixin {
this._gestures = new GestureHandler();
this._sock = new Websock();
this._sock.on('message', () => {
this._handleMessage();
});
this._sock.on('open', () => {
if ((this._rfbConnectionState === 'connecting') &&
(this._rfbInitState === '')) {
this._rfbInitState = 'ProtocolVersion';
Log.Debug("Starting VNC handshake");
} else {
this._fail("Unexpected server connection while " +
this._rfbConnectionState);
if (this._isPrimaryDisplay) {
this._setupWebSocket();
}
});
this._sock.on('close', (e) => {
Log.Debug("WebSocket on-close event");
let msg = "";
if (e.code) {
msg = "(code: " + e.code;
if (e.reason) {
msg += ", reason: " + e.reason;
}
msg += ")";
}
switch (this._rfbConnectionState) {
case 'connecting':
this._fail("Connection closed " + msg);
break;
case 'connected':
// Handle disconnects that were initiated server-side
this._updateConnectionState('disconnecting');
this._updateConnectionState('disconnected');
break;
case 'disconnecting':
// Normal disconnection path
this._updateConnectionState('disconnected');
break;
case 'disconnected':
this._fail("Unexpected server disconnect " +
"when already disconnected " + msg);
break;
default:
this._fail("Unexpected server disconnect before connecting " +
msg);
break;
}
this._sock.off('close');
// Delete reference to raw channel to allow cleanup.
this._rawChannel = null;
});
this._sock.on('error', e => Log.Warn("WebSocket on-error event"));
// Slight delay of the actual connection so that the caller has
// time to set up callbacks
setTimeout(this._updateConnectionState.bind(this, 'connecting'));
Log.Debug("<< RFB.constructor");
// ===== PROPERTIES =====
this.dragViewport = false;
this.focusOnClick = true;
this.lastActiveAt = Date.now();
@ -774,8 +734,10 @@ export default class RFB extends EventTargetMixin {
if (this._rfbConnectionState === 'connected') {
if (this._pendingApplyVideoRes) {
if (this._isPrimaryDisplay){
RFB.messages.setMaxVideoResolution(this._sock, this._maxVideoResolutionX, this._maxVideoResolutionY);
}
}
if (this._pendingApplyResolutionChange) {
this._requestRemoteResize();
@ -793,15 +755,12 @@ export default class RFB extends EventTargetMixin {
}
disconnect() {
if (this._rfbConnectionState !== 'proxied') {
this._updateConnectionState('disconnecting');
this._sock.off('error');
this._sock.off('message');
this._sock.off('open');
}
sendCredentials(creds) {
this._rfbCredentials = creds;
setTimeout(this._initMsg.bind(this), 0);
}
sendCtrlAltDel() {
@ -851,13 +810,21 @@ export default class RFB extends EventTargetMixin {
Log.Info("Sending key (" + (down ? "down" : "up") + "): keysym " + keysym + ", scancode " + scancode);
RFB.messages.QEMUExtendedKeyEvent(this._sock, keysym, down, scancode);
if (this._isPrimaryDisplay) {
RFB.messages.QEMUExtendedKeyEvent(this._sock, [ keysym, down, scancode]);
} else {
this._proxyRFBMessage('QEMUExtendedKeyEvent', [ keysym, down, scancode ])
}
} else {
if (!keysym) {
return;
}
Log.Info("Sending keysym (" + (down ? "down" : "up") + "): " + keysym);
if (this._isPrimaryDisplay) {
RFB.messages.keyEvent(this._sock, keysym, down ? 1 : 0);
} else {
this._proxyRFBMessage('keyEvent', [ keysym, down ? 1 : 0 ])
}
}
}
@ -909,7 +876,12 @@ export default class RFB extends EventTargetMixin {
let mimes = [ 'text/plain' ];
dataset.push(data);
if (this._isPrimaryDisplay) {
RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes);
} else {
this._proxyRFBMessage('sendBinaryClipboard', [ dataset, mimes ]);
}
}
async clipboardPasteDataFrom(clipdata) {
@ -973,24 +945,34 @@ export default class RFB extends EventTargetMixin {
if (dataset.length > 0) {
if (this._isPrimaryDisplay) {
RFB.messages.sendBinaryClipboard(this._sock, dataset, mimes);
} else {
this._proxyRFBMessage('sendBinaryClipboard', [ dataset, mimes ]);
}
}
}
requestBottleneckStats() {
if (this._isPrimaryDisplay) {
RFB.messages.requestStats(this._sock);
}
}
subscribeUnixRelay(name, processRelayFn) {
if (this._isPrimaryDisplay){
this._unixRelays = this._unixRelays || {};
this._unixRelays[name] = processRelayFn;
RFB.messages.sendSubscribeUnixRelay(this._sock, name);
}
}
sendUnixRelayData(name, payload) {
if (this._isPrimaryDisplay) {
RFB.messages.sendUnixRelay(this._sock, name, payload);
}
}
// ===== PRIVATE METHODS =====
@ -1003,10 +985,68 @@ export default class RFB extends EventTargetMixin {
this._transitConnectionState = value;
}
_setupWebSocket() {
this._sock = new Websock();
this._sock.on('message', () => {
this._handleMessage();
});
this._sock.on('open', () => {
if ((this._rfbConnectionState === 'connecting') &&
(this._rfbInitState === '')) {
this._rfbInitState = 'ProtocolVersion';
Log.Debug("Starting VNC handshake");
} else {
this._fail("Unexpected server connection while " +
this._rfbConnectionState);
}
});
this._sock.on('close', (e) => {
Log.Debug("WebSocket on-close event");
let msg = "";
if (e.code) {
msg = "(code: " + e.code;
if (e.reason) {
msg += ", reason: " + e.reason;
}
msg += ")";
}
switch (this._rfbConnectionState) {
case 'connecting':
this._fail("Connection closed " + msg);
break;
case 'connected':
// Handle disconnects that were initiated server-side
this._updateConnectionState('disconnecting');
this._updateConnectionState('disconnected');
break;
case 'disconnecting':
// Normal disconnection path
this._updateConnectionState('disconnected');
break;
case 'disconnected':
this._fail("Unexpected server disconnect " +
"when already disconnected " + msg);
break;
default:
this._fail("Unexpected server disconnect before connecting " +
msg);
break;
}
this._sock.off('close');
// Delete reference to raw channel to allow cleanup.
this._rawChannel = null;
});
this._sock.on('error', e => Log.Warn("WebSocket on-error event"));
// Slight delay of the actual connection so that the caller has
// time to set up callbacks
setTimeout(this._updateConnectionState.bind(this, 'connecting'));
}
_connect() {
Log.Debug(">> RFB.connect");
if (this._url) {
if (this._url && this._isPrimaryDisplay) {
try {
Log.Info(`connecting to ${this._url}`);
this._sock.open(this._url, this._wsProtocols);
@ -1018,13 +1058,15 @@ export default class RFB extends EventTargetMixin {
this._fail("Error when opening socket (" + e + ")");
}
}
} else {
} else if (this._isPrimaryDisplay) {
try {
Log.Info(`attaching ${this._rawChannel} to Websock`);
this._sock.attach(this._rawChannel);
} catch (e) {
this._fail("Error attaching channel (" + e + ")");
}
} else {
this._registerSecondaryDisplay();
}
// Make our elements part of the page
@ -1085,7 +1127,7 @@ export default class RFB extends EventTargetMixin {
this._resendClipboardNextUserDrivenEvent = true;
// WebRTC UDP datachannel inits
if (typeof RTCPeerConnection !== 'undefined') {
if (typeof RTCPeerConnection !== 'undefined' && this._isPrimaryDisplay) {
this._udpBuffer = new Map();
this._udpPeer = new RTCPeerConnection({
@ -1190,7 +1232,7 @@ export default class RFB extends EventTargetMixin {
}
}
if (this._useUdp && typeof RTCPeerConnection !== 'undefined') {
if (this._useUdp && typeof RTCPeerConnection !== 'undefined' && this._isPrimaryDisplay) {
setTimeout(function() { this._sendUdpUpgrade() }.bind(this), 3000);
}
@ -1224,7 +1266,17 @@ export default class RFB extends EventTargetMixin {
window.removeEventListener('focus', this._eventHandlers.handleFocusChange);
this._keyboard.ungrab();
this._gestures.detach();
if (this._isPrimaryDisplay) {
this._sock.close();
} else {
if (this._primaryDisplayChannel) {
this._primaryDisplayChannel.postMessage({eventType: 'unregister', screenID: this._screenID})
this._primaryDisplayChannel.removeEventListener('message', this._handleSecondaryDisplayMessage);
this._primaryDisplayChannel.close();
this._primaryDisplayChannel = null;
}
}
try {
this._target.removeChild(this._screen);
} catch (e) {
@ -1325,7 +1377,7 @@ export default class RFB extends EventTargetMixin {
// When clipping is enabled, the screen is limited to
// the size of the container.
const size = this._screenSize();
this._display.viewportChangeSize(size.w, size.h);
this._display.viewportChangeSize(size.screens[0].serverWidth, size.screens[0].serverHeight);
this._fixScrollbars();
}
}
@ -1335,7 +1387,7 @@ export default class RFB extends EventTargetMixin {
this._display.scale = 1.0;
} else {
const size = this._screenSize(false);
this._display.autoscale(size.w, size.h, size.scale);
this._display.autoscale(size.screens[0].containerWidth, size.screens[0].containerHeight, size.screens[0].scale);
}
this._fixScrollbars();
}
@ -1351,16 +1403,16 @@ export default class RFB extends EventTargetMixin {
return;
}
const size = this._screenSize();
RFB.messages.setDesktopSize(this._sock,
Math.floor(size.w), Math.floor(size.h),
this._screenID, this._screenFlags);
RFB.messages.setDesktopSize(this._sock, size, this._screenFlags);
Log.Debug('Requested new desktop size: ' +
size.w + 'x' + size.h);
size.serverWidth + 'x' + size.serverHeight);
}
// Gets the the size of the available screen
_screenSize (limited) {
return this._display.getScreenSize(this.videoQuality, this.forcedResolutionX, this.forcedResolutionY, this._hiDpi, limited);
if (limited === undefined) {
limited = true;
}
@ -1469,6 +1521,10 @@ export default class RFB extends EventTargetMixin {
}
break;
case 'proxied':
//secondary display that needs to proxy messages through the broadcast channel
break;
default:
Log.Error("Unknown connection state: " + state);
return;
@ -1486,8 +1542,10 @@ export default class RFB extends EventTargetMixin {
this._disconnTimer = null;
// make sure we don't get a double event
if (this._rfbConnectionState !== 'proxied') {
this._sock.off('close');
}
}
switch (state) {
case 'connecting':
@ -1550,6 +1608,62 @@ export default class RFB extends EventTargetMixin {
{ detail: { capabilities: this._capabilities } }));
}
_registerSecondaryDisplay() {
this._primaryDisplayChannel = new BroadcastChannel(`screen_${this._screenID}_channel`);
this._primaryDisplayChannel.message = this._handleSecondaryDisplayMessage.bind(this);
const size = this._screenSize();
message = {
eventType: 'register',
screenID: this._screenID,
screenIndex: this._screenIndex,
width: size.w,
height: size.h,
x: 0,
y: 0,
relativePosition: 0,
pixelRatio: window.devicePixelRatio,
containerWidth: this._screen.offsetWidth,
containerHeight: this._screen.offsetWidth,
channel: null
}
this._controlChannel.postMessage(message)
}
_proxyRFBMessage(messageType, data) {
message = {
messageType: messageType,
data: data
}
this._controlChannel.postMessage(message);
}
_handleControlMessage(event) {
console.log(event);
switch (event.eventType) {
case 'register':
this._display.addScreen(event.screenID, event.width, event.height, event.relativePosition, event.pixelRatio, event.containerHeight, event.containerWidth);
Log.Info(`Secondary monitor (${event.screenID}) has been registered.`);
break;
case 'unregister':
if (this._display.removeScreen(event.screenID)) {
Log.Info(`Secondary monitor (${event.screenID}) has been removed.`);
} else {
Log.Info(`Secondary monitor (${event.screenID}) not found.`);
}
}
}
_handleSecondaryDisplayMessage(event) {
console.log("Message Received: " + event);
if (this._isPrimaryDisplay) {
}
}
_handleMessage() {
if (this._sock.rQlen === 0) {
Log.Warn("handleMessage called on an empty receive queue");
@ -1790,16 +1904,23 @@ export default class RFB extends EventTargetMixin {
//console.log("new_pos x" + x + ", y" + y);
//console.log("lock x " + this._pointerLockPos.x + ", y " + this._pointerLockPos.y);
//console.log("rel x " + rel_16_x + ", y " + rel_16_y);
if (this._isPrimaryDisplay){
RFB.messages.pointerEvent(this._sock, rel_16_x, rel_16_y, mask);
} else {
this._proxyRFBMessage('pointerEvent', [ rel_16_x, rel_16_y, mask ]);
}
RFB.messages.pointerEvent(this._sock, rel_16_x,
rel_16_y, mask);
// reset the cursor position to center
this._mousePos = { x: this._pointerLockPos.x , y: this._pointerLockPos.y };
this._cursor.move(this._pointerLockPos.x, this._pointerLockPos.y);
} else {
RFB.messages.pointerEvent(this._sock, this._display.absX(x),
this._display.absY(y), mask);
if (this._isPrimaryDisplay) {
RFB.messages.pointerEvent(this._sock, this._display.absX(x), this._display.absY(y), mask);
} else {
this._proxyRFBMessage('pointerEvent', [ this._display.absX(x), this._display.absY(y), mask ]);
}
}
}
@ -1808,7 +1929,12 @@ export default class RFB extends EventTargetMixin {
if (this._rfbConnectionState !== 'connected') { return; }
if (this._viewOnly) { return; } // View only, skip mouse events
if (this.isPrimaryDisplay){
RFB.messages.pointerEvent(this._sock, this._display.absX(x), this._display.absY(y), 0, dX, dY);
} else {
this._proxyRFBMessage('pointerEvent', [ this._display.absX(x), this._display.absY(y), 0, dX, dY ]);
}
}
_handleWheel(ev) {
@ -3570,7 +3696,7 @@ export default class RFB extends EventTargetMixin {
for (let i = 0; i < numberOfScreens; i += 1) {
// Save the id and flags of the first screen
if (i === 0) {
this._screenID = this._sock.rQshiftBytes(4); // id
this._screenIndex = this._sock.rQshiftBytes(4); // id
this._sock.rQskipBytes(2); // x-position
this._sock.rQskipBytes(2); // y-position
this._sock.rQskipBytes(2); // width
@ -4061,20 +4187,50 @@ RFB.messages = {
}
},
setDesktopSize(sock, width, height, id, flags) {
setDesktopSize(sock, size, flags) {
const buff = sock._sQ;
const offset = sock._sQlen;
buff[offset] = 251; // msg-type
buff[offset + 1] = 0; // padding
buff[offset + 2] = width >> 8; // width
buff[offset + 3] = width;
buff[offset + 4] = height >> 8; // height
buff[offset + 5] = height;
buff[offset + 2] = size.serverWidth >> 8; // width
buff[offset + 3] = size.serverWidth;
buff[offset + 4] = size.serverHeight >> 8; // height
buff[offset + 5] = size.serverHeight;
buff[offset + 6] = 1; // number-of-screens
buff[offset + 6] = size.screens.length; // number-of-screens
buff[offset + 7] = 0; // padding
let i = 8;
for (let iS = 0; iS < size.screens.length; iS++) {
//screen id
buff[offset + i++] = iS >> 24;
buff[offset + i++] = iS >> 16;
buff[offset + i++] = iS >> 8;
buff[offset + i++] = iS;
//screen x position
buff[offset + i++] = size.screens[iS].x >> 8;
buff[offset + i++] = size.screens[iS].x;
//screen y position
buff[offset + i++] = size.screens[iS].y >> 8;
buff[offset + i++] = size.screens[iS].y;
//screen width
buff[offset + i++] = size.screens[iS].serverWidth >> 8;
buff[offset + i++] = size.screens[iS].serverWidth;
//screen height
buff[offset + i++] = size.screens[iS].serverHeight >> 8;
buff[offset + i++] = size.screens[iS].serverHeight;
//flags
buff[offset + i++] = flags >> 24;
buff[offset + i++] = flags >> 16;
buff[offset + i++] = flags >> 8;
buff[offset + i++] = flags;
}
sock._sQlen += i;
sock.flush();
/*
// screen array
buff[offset + 8] = id >> 24; // id
buff[offset + 9] = id >> 16;
@ -4095,6 +4251,7 @@ RFB.messages = {
sock._sQlen += 24;
sock.flush();
*/
},
setMaxVideoResolution(sock, width, height) {

View File

@ -26,3 +26,9 @@ export function decodeUTF8(utf8string, allowLatin1=false) {
export function encodeUTF8(DOMString) {
return unescape(encodeURIComponent(DOMString));
}
export function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}

95
screen.html Normal file
View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en" class="noVNC_loading">
<head>
<!--
noVNC is licensed under the MPL 2.0 (see LICENSE.txt)
This file is licensed under the 2-Clause BSD license (see LICENSE.txt).
-->
<title>KasmVNC</title>
<meta charset="utf-8">
<!-- Icons (see app/images/icons/Makefile for what the sizes are for) -->
<link rel="icon" sizes="16x16" type="image/png" href="app/images/icons/368_kasm_logo_only_16x16.png">
<link rel="icon" sizes="24x24" type="image/png" href="app/images/icons/368_kasm_logo_only_24x24.png">
<link rel="icon" sizes="32x32" type="image/png" href="app/images/icons/368_kasm_logo_only_32x32.png">
<link rel="icon" sizes="48x48" type="image/png" href="app/images/icons/368_kasm_logo_only_48x48.png">
<link rel="icon" sizes="60x60" type="image/png" href="app/images/icons/368_kasm_logo_only_60x60.png">
<link rel="icon" sizes="64x64" type="image/png" href="app/images/icons/368_kasm_logo_only_64x64.png">
<link rel="icon" sizes="72x72" type="image/png" href="app/images/icons/368_kasm_logo_only_72x72.png">
<link rel="icon" sizes="76x76" type="image/png" href="app/images/icons/368_kasm_logo_only_76x76.png">
<link rel="icon" sizes="96x96" type="image/png" href="app/images/icons/368_kasm_logo_only_96x96.png">
<link rel="icon" sizes="120x120" type="image/png" href="app/images/icons/368_kasm_logo_only_120x120.png">
<link rel="icon" sizes="144x144" type="image/png" href="app/images/icons/368_kasm_logo_only_144x144.png">
<link rel="icon" sizes="152x152" type="image/png" href="app/images/icons/368_kasm_logo_only_152x152.png">
<link rel="icon" sizes="192x192" type="image/png" href="app/images/icons/368_kasm_logo_only_192x192.png">
<!-- Firefox currently mishandles SVG, see #1419039
<link rel="icon" sizes="any" type="image/svg+xml" href="app/images/icons/kasm-icon.svg">
-->
<!-- Repeated last so that legacy handling will pick this -->
<link rel="icon" sizes="16x16" type="image/png" href="app/images/icons/368_kasm_logo_only_16x16.png">
<!-- Apple iOS Safari settings -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Home Screen Icons (favourites and bookmarks use the normal icons) -->
<link rel="apple-touch-icon" sizes="60x60" type="image/png" href="app/images/icons/368_kasm_logo_only_60x60.png">
<link rel="apple-touch-icon" sizes="76x76" type="image/png" href="app/images/icons/368_kasm_logo_only_76x76.png">
<link rel="apple-touch-icon" sizes="120x120" type="image/png" href="app/images/icons/368_kasm_logo_only_120x120.png">
<link rel="apple-touch-icon" sizes="152x152" type="image/png" href="app/images/icons/368_kasm_logo_only_152x152.png">
<script src="vendor/interact.min.js"></script>
<!-- Stylesheets -->
<link rel="stylesheet" href="app/styles/base.css">
<script src="app/error-handler.js"></script>
<script>
let isInsideKasmVDI = false;
try {
isInsideKasmVDI = (window.self !== window.top);
} catch (e) {
isInsideKasmVDI = true;
}
</script>
<script type="module" crossorigin="use-credentials" src="app/ui_screen.js"></script>
</head>
<body>
<div id="noVNC_fallback_error" class="noVNC_center">
<div>
<div id="noVNC_close_error" onclick="document.getElementById('noVNC_fallback_error').remove()"></div>
<div>KasmVNC encountered an error:</div>
<br>
<div id="noVNC_fallback_errormsg"></div>
</div>
</div>
<!-- Status Dialog -->
<div id="noVNC_status"></div>
<!-- Connect button -->
<div class="noVNC_center">
<div id="noVNC_connect_dlg">
<div id="noVNC_connect_button">
<div>
<img alt="" src="app/images/connect.svg"> Connect
</div>
</div>
</div>
</div>
<!-- This is where the RFB elements will attach -->
<div id="noVNC_container">
<!-- Note that Google Chrome on Android doesn't respect any of these,
html attributes which attempt to disable text suggestions on the
on-screen keyboard. Let's hope Chrome implements the ime-mode
style for example -->
<textarea id="noVNC_keyboardinput" autocapitalize="off"
autocomplete="off" spellcheck="false" tabindex="-1"></textarea>
</div>
</body>

View File

@ -50,7 +50,7 @@
<script src="vendor/interact.min.js"></script>
<!-- Stylesheets -->
<!--link rel="stylesheet" href="app/styles/base.css">
<link rel="stylesheet" href="app/styles/base.css">
<script src="app/error-handler.js"></script>
@ -63,7 +63,7 @@
}
</script>
<script type="module" crossorigin="use-credentials" src="app/ui.js"></script-->
<script type="module" crossorigin="use-credentials" src="app/ui.js"></script>
</head>
<body>
@ -185,6 +185,14 @@
Fullscreen
</div>
<!-- Add Second Screen -->
<div class="noVNC_button_div noVNC_hidden" >
<input type="image" alt="Fullscreen" src="app/images/fullscreen.svg"
id="noVNC_addmonitor_button" class="noVNC_button"
title="Add Monitor">
Add Monitor
</div>
<!-- Toggle game mode -->
<div class="noVNC_button_div noVNC_hidden noVNC_hide_on_disconnect" >
<input type="image" alt="Game Mode" src="app/images/gamepad.png"
@ -548,25 +556,6 @@
</div>
</div>
<!-- Password Dialog -->
<div class="noVNC_center noVNC_connect_layer">
<div id="noVNC_credentials_dlg" class="noVNC_panel"><form>
<ul>
<li id="noVNC_username_block">
<label>Username:</label>
<input id="noVNC_username_input">
</li>
<li id="noVNC_password_block">
<label>Password:</label>
<input id="noVNC_password_input" type="password">
</li>
<li>
<input id="noVNC_credentials_button" type="submit" value="Send Credentials" class="noVNC_submit">
</li>
</ul>
</form></div>
</div>
<!-- Transition Screens -->
<div id="noVNC_transition">
<div id="noVNC_transition_text"></div>