diff --git a/app/ui.js b/app/ui.js index 51e57bd3..0f68d398 100644 --- a/app/ui.js +++ b/app/ui.js @@ -185,6 +185,7 @@ const UI = { UI.initSetting('bell', 'on'); UI.initSetting('view_only', false); UI.initSetting('show_dot', false); + UI.initSetting('gestures_mode', 'novnc'); UI.initSetting('path', 'websockify'); UI.initSetting('repeaterID', ''); UI.initSetting('reconnect', false); @@ -371,6 +372,7 @@ const UI = { UI.addSettingChangeHandler('view_only', UI.updateViewOnly); UI.addSettingChangeHandler('show_dot'); UI.addSettingChangeHandler('show_dot', UI.updateShowDotCursor); + UI.addSettingChangeHandler('gestures_mode'); UI.addSettingChangeHandler('host'); UI.addSettingChangeHandler('port'); UI.addSettingChangeHandler('path'); @@ -441,6 +443,7 @@ const UI = { UI.disableSetting('port'); UI.disableSetting('path'); UI.disableSetting('repeaterID'); + UI.disableSetting('gestures_mode'); // Hide the controlbar after 2 seconds UI.closeControlbarTimeout = setTimeout(UI.closeControlbar, 2000); @@ -451,6 +454,7 @@ const UI = { UI.enableSetting('port'); UI.enableSetting('path'); UI.enableSetting('repeaterID'); + UI.enableSetting('gestures_mode'); UI.updatePowerButton(); UI.keepControlbar(); } @@ -1077,7 +1081,8 @@ const UI = { url.href, { shared: UI.getSetting('shared'), repeaterID: UI.getSetting('repeaterID'), - credentials: { password: password } }); + credentials: { password: password }, + gesturesMode: UI.getSetting('gestures_mode') }); } catch (exc) { Log.Error("Failed to connect to server: " + exc); UI.updateVisualState('disconnected'); diff --git a/core/encodings.js b/core/encodings.js index 7afcb17f..50dba0af 100644 --- a/core/encodings.js +++ b/core/encodings.js @@ -25,6 +25,7 @@ export const encodings = { pseudoEncodingCursor: -239, pseudoEncodingQEMUExtendedKeyEvent: -258, pseudoEncodingQEMULedEvent: -261, + pseudoEncodingGii: -305, pseudoEncodingDesktopName: -307, pseudoEncodingExtendedDesktopSize: -308, pseudoEncodingXvp: -309, diff --git a/core/input/touchhandlerultravnc.js b/core/input/touchhandlerultravnc.js new file mode 100644 index 00000000..f44a660e --- /dev/null +++ b/core/input/touchhandlerultravnc.js @@ -0,0 +1,170 @@ +import * as Log from '../util/logging.js'; + +export default class TouchHandlerUltraVNC { + static PF_flag = 0x80000000; // Pressed Flag : active if the touch event is pressed, inactive if it's being released. + static R1_flag = 0x40000000; // Reserved 1 + static IF_flag = 0x20000000; // Primary Flag : active if the touch event is the primary touch event. + static S1_flag = 0x10000000; // Size Flag : active if the message contains information about the size of the touch event. The events are currently all sent as symetrical ellipses. + static S2_flag = 0x8000000; // Reserved for asymetrical ellipses. Not supported yet and should be 0. + static RT_flag = 0x4000000; // Rectangle : the touch event is a rectangle instead of an ellipse. + static PR_flag = 0x2000000; // Pressure Flag : pressure of the touch. Currently unused. + static TI_flag = 0x1000000; // Timestamp : the timestamp of the touch event. + static HC_flag = 0x800000; // High Performance Counter + + static LENGTH_16_flag = 0x10; // 16 bits signed for x touch coordinate followed by 16 bits signed for y together in a 32 bits word + static IDFORMAT_32 = 0x1; // 32 bits ID + static IDFORMAT_CLEAR = 0xF; // No more touch points + + // GII + static giiMsgType = 253; + static giiEventInjectionMsgType = 128; + static giiDeviceVersionMsgType = 129; + static giiDeviceCreationMsgType = 130; + + static giiDeviceCreationMsgSize = 172; + static giiDeviceVersion = 1; + static giiDeviceVersionMsgSize = 2; + + static giiEventInjectionHeaderSize = 4; + static giiEventInjectionSize = this.giiEventInjectionHeaderSize + 16; + static giiEventInjectionTouchSize = 12; + static giiEventInjectionEventType = 12; + + static giiDeviceName = "NOVNC-MT"; + static giiDeviceNameSize = 31; + static giiDeviceLongName = "noVNC Multitouch Device"; + static giiDeviceLongNameSize = 74; + static giiDeviceShortName = "NMD"; + static giiDeviceShortNameSize = 4; + + static giiDNTerm = 0; + static giiVendorID = 0x0908; + static giiProductID = 0x000b; + static giiEventMask = 0x00002000; + static giiNumRegisters = 0; + static giiNumValuators = 1; + static giiNumButtons = 5; + static giiNumTouches = 6; + static giiIndex = 0; + static giiLNTerm = 0; + static giiSNTerm = 0; + static giiRangeMin = 0; + static giiRangeCenter = 0; + static giiRangeMax = 0; + static giiSIUnit = 0; + static giiSIAdd = 0; + static giiSIMul = 0; + static giiSIDiv = 0; + static giiSIShift = 0; + + constructor() { + this._target = null; + + this._currentTouches = []; + this._sendTouchesIntervalId = -1; + this._giiDeviceOrigin = 0; + this._isUltraVNCTouchActivated = false; + + this._boundEventHandler = this._handleTouch.bind(this); + } + + attach(target) { + this.detach(); + + this._target = target; + this._target.addEventListener('touchstart', + this._boundEventHandler); + this._target.addEventListener('touchmove', + this._boundEventHandler); + this._target.addEventListener('touchend', + this._boundEventHandler); + this._target.addEventListener('touchcancel', + this._boundEventHandler); + } + + detach() { + if (!this._target) { + return; + } + + this._target.removeEventListener('touchstart', + this._boundEventHandler); + this._target.removeEventListener('touchmove', + this._boundEventHandler); + this._target.removeEventListener('touchend', + this._boundEventHandler); + this._target.removeEventListener('touchcancel', + this._boundEventHandler); + + clearInterval(this._sendTouchesIntervalId); + this._sendTouchesIntervalId = -1; + + this._target = null; + } + + _handleTouch(ev) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + + if (!this._isUltraVNCTouchActivated) { + return; + } + + if (ev.type === "touchstart") { + for (let i = 0; i < ev.changedTouches.length; i++) { + ev.changedTouches[i].touchIdentifier = this._getTouchIdentifier(); + this._currentTouches.push({ event: ev.changedTouches[i], status: "POINTER_DOWN" }); + } + + if (this._sendTouchesIntervalId === -1 && this._target) { + this._dispatchTouchEvent(ev); + this._sendTouchesIntervalId = setInterval(() => { + this._dispatchTouchEvent(ev); + }, 200); + } + } else if (ev.type === "touchmove") { + for (let i = 0; i < ev.changedTouches.length; i++) { + const index = this._currentTouches.findIndex(t => t.event.identifier === ev.changedTouches[i].identifier); + if (index !== -1) { + ev.changedTouches[i].touchIdentifier = this._currentTouches[index].event.touchIdentifier; + this._currentTouches[index].event = ev.changedTouches[i]; + this._currentTouches[index].status = "POINTER_UPDATE"; + } + } + } else if (ev.type === "touchend" || ev.type === "touchcancel") { + for (let i = 0; i < ev.changedTouches.length; i++) { + const indexes = this._getAllIndexes(this._currentTouches, (t) => t.event.identifier === ev.changedTouches[i].identifier) + indexes.forEach((index) => this._currentTouches[index].status = "POINTER_UP"); + } + } + } + + _getAllIndexes(arr, func) { + var indexes = [], i; + for (i = 0; i < arr.length; i++) + if (func(arr[i])) + indexes.push(i); + return indexes; + } + + _getTouchIdentifier() { + const ids = this._currentTouches.map((ev) => ev.event.touchIdentifier); + let i = 0; + while (ids.includes(i)) { i++; } + return i; + } + + _dispatchTouchEvent(ev) { + let tev = new CustomEvent('ultravnctouch', { event: ev, detail: { currentTouches: this._currentTouches, giiDeviceOrigin: this._giiDeviceOrigin } }); + this._target.dispatchEvent(tev); + } + + _removeTouch(index) { + this._currentTouches.splice(index, 1); + } + + _interruptTouches() { + clearInterval(this._sendTouchesIntervalId); + this._sendTouchesIntervalId = -1; + } +} \ No newline at end of file diff --git a/core/rfb.js b/core/rfb.js index 80011e4a..ef8b984e 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -19,6 +19,7 @@ import Inflator from "./inflator.js"; import Deflator from "./deflator.js"; import Keyboard from "./input/keyboard.js"; import GestureHandler from "./input/gesturehandler.js"; +import TouchHandlerUltraVNC from "./input/touchhandlerultravnc.js"; import Cursor from "./util/cursor.js"; import Websock from "./websock.js"; import KeyTable from "./input/keysym.js"; @@ -119,6 +120,7 @@ export default class RFB extends EventTargetMixin { this._shared = 'shared' in options ? !!options.shared : true; this._repeaterID = options.repeaterID || ''; this._wsProtocols = options.wsProtocols || []; + this._gesturesMode = options.gesturesMode || '' // Internal state this._rfbConnectionState = ''; @@ -208,6 +210,7 @@ export default class RFB extends EventTargetMixin { handleMouse: this._handleMouse.bind(this), handleWheel: this._handleWheel.bind(this), handleGesture: this._handleGesture.bind(this), + handleUltraVNCTouch: this._handleUltraVNCTouch.bind(this), handleRSAAESCredentialsRequired: this._handleRSAAESCredentialsRequired.bind(this), handleRSAAESServerVerification: this._handleRSAAESServerVerification.bind(this), }; @@ -271,7 +274,13 @@ export default class RFB extends EventTargetMixin { this._remoteCapsLock = null; // Null indicates unknown or irrelevant this._remoteNumLock = null; - this._gestures = new GestureHandler(); + switch (this._gesturesMode) { + case 'ultravnc': + this._gestures = new TouchHandlerUltraVNC(); + break; + default: + this._gestures = new GestureHandler(); + } this._sock = new Websock(); this._sock.on('open', this._socketOpen.bind(this)); @@ -598,9 +607,15 @@ export default class RFB extends EventTargetMixin { this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel); // Gesture events - this._canvas.addEventListener("gesturestart", this._eventHandlers.handleGesture); - this._canvas.addEventListener("gesturemove", this._eventHandlers.handleGesture); - this._canvas.addEventListener("gestureend", this._eventHandlers.handleGesture); + switch (this._gesturesMode) { + case 'ultravnc': + this._canvas.addEventListener('ultravnctouch', this._eventHandlers.handleUltraVNCTouch); + break; + default: + this._canvas.addEventListener('gesturestart', this._eventHandlers.handleGesture); + this._canvas.addEventListener('gesturemove', this._eventHandlers.handleGesture); + this._canvas.addEventListener('gestureend', this._eventHandlers.handleGesture); + } Log.Debug("<< RFB.connect"); } @@ -608,9 +623,17 @@ export default class RFB extends EventTargetMixin { _disconnect() { Log.Debug(">> RFB.disconnect"); this._cursor.detach(); - this._canvas.removeEventListener("gesturestart", this._eventHandlers.handleGesture); - this._canvas.removeEventListener("gesturemove", this._eventHandlers.handleGesture); - this._canvas.removeEventListener("gestureend", this._eventHandlers.handleGesture); + + switch (this._gesturesMode) { + case 'ultravnc': + this._canvas.removeEventListener('ultravnctouch', this._eventHandlers.handleUltraVNCTouch); + break; + default: + this._canvas.removeEventListener('gesturestart', this._eventHandlers.handleGesture); + this._canvas.removeEventListener('gesturemove', this._eventHandlers.handleGesture); + this._canvas.removeEventListener('gestureend', this._eventHandlers.handleGesture); + } + this._canvas.removeEventListener("wheel", this._eventHandlers.handleWheel); this._canvas.removeEventListener('mousedown', this._eventHandlers.handleMouse); this._canvas.removeEventListener('mouseup', this._eventHandlers.handleMouse); @@ -1483,6 +1506,84 @@ export default class RFB extends EventTargetMixin { } } + _handleUltraVNCTouch(ev) { + this._sock.sQpush8(TouchHandlerUltraVNC.giiMsgType); // GII message type + this._sock.sQpush8(TouchHandlerUltraVNC.giiEventInjectionMsgType); // GII event + this._sock.sQpush16(TouchHandlerUltraVNC.giiEventInjectionSize + (TouchHandlerUltraVNC.giiEventInjectionTouchSize * ev.detail.currentTouches.length)); // length, not used + this._sock.sQpush8(TouchHandlerUltraVNC.giiEventInjectionSize + (TouchHandlerUltraVNC.giiEventInjectionTouchSize * ev.detail.currentTouches.length)); // eventSize, not used + this._sock.sQpush8(TouchHandlerUltraVNC.giiEventInjectionEventType); // eventType, not used + this._sock.sQpush16(0); // padding + this._sock.sQpush32(ev.detail.giiDeviceOrigin); + this._sock.sQpush32(ev.detail.currentTouches.length); // nb of touch events + this._sock.sQpush32(TouchHandlerUltraVNC.giiNumTouches * ev.detail.currentTouches.length); // count + + let pointerUpIds = []; + + // Send all current touches + for (let i = 0; i < ev.detail.currentTouches.length; i++) { + let valuatorFlag = 0x00000000; + valuatorFlag |= TouchHandlerUltraVNC.LENGTH_16_flag; + valuatorFlag |= TouchHandlerUltraVNC.IDFORMAT_32; + if (ev.detail.currentTouches[i].status !== "POINTER_UP") valuatorFlag |= TouchHandlerUltraVNC.PF_flag; + if (ev.detail.currentTouches[i].event.identifier === 0) valuatorFlag |= TouchHandlerUltraVNC.IF_flag; // IF_flag + + this._sock.sQpush32(valuatorFlag); + this._sock.sQpush32(ev.detail.currentTouches[i].event.touchIdentifier); + + let scaledPosition = clientToElement(ev.detail.currentTouches[i].event.clientX, ev.detail.currentTouches[i].event.clientY, + this._canvas); + + if ((valuatorFlag & TouchHandlerUltraVNC.LENGTH_16_flag) !== 0) { + let scaledX16 = this._display.absX(scaledPosition.x) & 0xFFFF; + let scaledY16 = this._display.absY(scaledPosition.y) & 0xFFFF; + let coordinates = (scaledX16 << 16) | scaledY16; + this._sock.sQpush32(coordinates); + } + + // Keep track of last released touches + if (ev.detail.currentTouches[i].status === "POINTER_UP") { + pointerUpIds.push(ev.detail.currentTouches[i].event.identifier); + } + } + + this._sock.flush(); + + // Remove released touches from current touches in handler + for (let i = 0; i < pointerUpIds.length; i++) { + const index = ev.detail.currentTouches.findIndex(t => t.event.identifier === pointerUpIds[i]); + if (index !== -1) { + this._gestures._removeTouch(index); + } + } + + // Interrupt touch sending interval + if (ev.detail.currentTouches.length === 0 && this._sendTouchesIntervalId !== -1) { + this._gestures._interruptTouches(); + this._sendEmptyTouch(ev.detail.giiDeviceOrigin); + return; + } + } + + _sendEmptyTouch(giiDeviceOrigin) { + let valuatorFlag = 0x00000000; + valuatorFlag |= TouchHandlerUltraVNC.LENGTH_16_flag; + valuatorFlag |= TouchHandlerUltraVNC.IDFORMAT_CLEAR; + + this._sock.sQpush8(TouchHandlerUltraVNC.giiMsgType); // GII message type + this._sock.sQpush8(TouchHandlerUltraVNC.giiEventInjectionMsgType); // GII event + this._sock.sQpush16(TouchHandlerUltraVNC.giiEventInjectionHeaderSize + TouchHandlerUltraVNC.giiEventInjectionSize); + this._sock.sQpush8(TouchHandlerUltraVNC.giiEventInjectionHeaderSize + TouchHandlerUltraVNC.giiEventInjectionSize); + this._sock.sQpush8(TouchHandlerUltraVNC.giiEventInjectionEventType); + this._sock.sQpush16(0); // padding + this._sock.sQpush32(giiDeviceOrigin); // deviceOrigin + this._sock.sQpush32(1); // nb of touchevents + this._sock.sQpush32(4); // nb of values, not used + this._sock.sQpush32(valuatorFlag); + this._sock.sQpush32(0); // empty Id + this._sock.sQpush32(0); // empty coordinates + this._sock.flush(); + } + _flushMouseMoveTimer(x, y) { if (this._mouseMoveTimer !== null) { clearTimeout(this._mouseMoveTimer); @@ -2261,6 +2362,10 @@ export default class RFB extends EventTargetMixin { encs.push(encodings.pseudoEncodingExtendedClipboard); encs.push(encodings.pseudoEncodingExtendedMouseButtons); + if (this._gesturesMode === 'ultravnc') { + encs.push(encodings.pseudoEncodingGii); + } + if (this._fbDepth == 24) { encs.push(encodings.pseudoEncodingVMwareCursor); encs.push(encodings.pseudoEncodingCursor); @@ -2556,6 +2661,30 @@ export default class RFB extends EventTargetMixin { return true; } + _handleGiiMsg() { + if (this._sock.rQwait("GII message subtype", 1, 1)) { + return false; + } + let giiMsgSubtype = this._sock.rQshift8(); + + switch (giiMsgSubtype) { + case 129: // GII Version Message + if (this._sock.rQwait("GII version message", 34, 1)) { return false; } + this._sock.rQskipBytes(34); + RFB.messages.giiVersionMessage(this._sock); + RFB.messages.giiDeviceCreation(this._sock); + break; + case 130: // GII Device Creation + if (this._sock.rQwait("GII device creation", 6, 1)) { return false; } + this._sock.rQshiftBytes(2); + this._gestures._giiDeviceOrigin = this._sock.rQshift32(); + if (this._gestures._giiDeviceOrigin) this._gestures._isUltraVNCTouchActivated = true; + break; + } + + return true; + } + _normalMsg() { let msgType; if (this._FBU.rects > 0) { @@ -2607,6 +2736,9 @@ export default class RFB extends EventTargetMixin { case 250: // XVP return this._handleXvpMsg(); + case 253: // GII + return this._handleGiiMsg(); + default: this._fail("Unexpected server message (type " + msgType + ")"); Log.Debug("sock.rQpeekBytes(30): " + this._sock.rQpeekBytes(30)); @@ -3070,6 +3202,16 @@ export default class RFB extends EventTargetMixin { "raw", passwordChars, { name: "DES-ECB" }, false, ["encrypt"]); return legacyCrypto.encrypt({ name: "DES-ECB" }, key, challenge); } + + static stringAsByteArrayWithPadding(str, size) { + let full = new Uint8Array(size); + let utf8Encode = new TextEncoder(); + let strArray = utf8Encode.encode(str); + for (let i = 0; i < strArray.length; i++) { + full[i] = strArray[i]; + } + return full; + } } // Class Methods @@ -3386,6 +3528,44 @@ RFB.messages = { sock.sQpush8(ver); sock.sQpush8(op); + sock.flush(); + }, + + giiVersionMessage(sock) { + sock.sQpush8(TouchHandlerUltraVNC.giiMsgType); + sock.sQpush8(TouchHandlerUltraVNC.giiDeviceVersionMsgType); + sock.sQpush16(TouchHandlerUltraVNC.giiDeviceVersionMsgSize); + sock.sQpush16(TouchHandlerUltraVNC.giiDeviceVersion); + + sock.flush(); + }, + + giiDeviceCreation(sock) { + sock.sQpush8(TouchHandlerUltraVNC.giiMsgType); + sock.sQpush8(TouchHandlerUltraVNC.giiDeviceCreationMsgType); + sock.sQpush16(TouchHandlerUltraVNC.giiDeviceCreationMsgSize); + sock.sQpushBytes(RFB.stringAsByteArrayWithPadding(TouchHandlerUltraVNC.giiDeviceName, TouchHandlerUltraVNC.giiDeviceNameSize)); + sock.sQpush8(TouchHandlerUltraVNC.giiDNTerm); + sock.sQpush32(TouchHandlerUltraVNC.giiVendorID); + sock.sQpush32(TouchHandlerUltraVNC.giiProductID); + sock.sQpush32(TouchHandlerUltraVNC.giiEventMask); + sock.sQpush32(TouchHandlerUltraVNC.giiNumRegisters); + sock.sQpush32(TouchHandlerUltraVNC.giiNumValuators); + sock.sQpush32(TouchHandlerUltraVNC.giiNumButtons); + sock.sQpush32(TouchHandlerUltraVNC.giiIndex); + sock.sQpushBytes(RFB.stringAsByteArrayWithPadding(TouchHandlerUltraVNC.giiDeviceLongName, TouchHandlerUltraVNC.giiDeviceLongNameSize)); + sock.sQpush8(TouchHandlerUltraVNC.giiLNTerm); + sock.sQpushBytes(RFB.stringAsByteArrayWithPadding(TouchHandlerUltraVNC.giiDeviceShortName, TouchHandlerUltraVNC.giiDeviceShortNameSize)); + sock.sQpush8(TouchHandlerUltraVNC.giiSNTerm); + sock.sQpush32(TouchHandlerUltraVNC.giiRangeMin); + sock.sQpush32(TouchHandlerUltraVNC.giiRangeCenter); + sock.sQpush32(TouchHandlerUltraVNC.giiRangeMax); + sock.sQpush32(TouchHandlerUltraVNC.giiSIUnit); + sock.sQpush32(TouchHandlerUltraVNC.giiSIAdd); + sock.sQpush32(TouchHandlerUltraVNC.giiSIMul); + sock.sQpush32(TouchHandlerUltraVNC.giiSIDiv); + sock.sQpush32(TouchHandlerUltraVNC.giiSIShift); + sock.flush(); } }; diff --git a/vnc.html b/vnc.html index 82cacd58..e731f2af 100644 --- a/vnc.html +++ b/vnc.html @@ -295,6 +295,11 @@ class="toggle"> Show dot when no cursor + +