Skip to content

Instantly share code, notes, and snippets.

@ovcharik
Created December 2, 2021 10:31
Show Gist options
  • Save ovcharik/890c81ef620b4736c155bc901acbec40 to your computer and use it in GitHub Desktop.
Save ovcharik/890c81ef620b4736c155bc901acbec40 to your computer and use it in GitHub Desktop.
rxjs wrapper for @novnc/novnc
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="./types/input-keysym.d.ts" />
/// <reference path="./types/input-utl.d.ts" />
/// <reference path="./types/util-browser.d.ts" />
/// <reference path="./types/rfb.d.ts" />
import * as NovncBrowserUtils from '@novnc/novnc/core/util/browser';
import * as NovncInputUtils from '@novnc/novnc/core/input/util';
import NovncKeysym from '@novnc/novnc/core/input/keysym';
import NovncClient from '@novnc/novnc/core/rfb';
export { NovncClient, NovncBrowserUtils, NovncInputUtils, NovncKeysym };
export { NovncCredentials, NovncOptions } from '@novnc/novnc/core/rfb';
export { NovncEvents, NovncEventType, NovncEvent } from '@novnc/novnc/core/rfb';
import { isNil } from 'lodash';
import { from, of, Subject } from 'rxjs';
import { bufferCount, bufferTime, concatAll, concatMap, delay, switchMap } from 'rxjs/operators';
import { NovncKeyboardKey, NovncKeyboardLayout } from './novnc-keyboard';
import { NovncKeysym, NovncClient } from './novnc-core';
type SendKeyArgShort = [number];
type SendKeyArgMiddle = [number, string | null];
type SendKeyArgFull = [number, string | null, boolean];
type SendKeyArgsAll = SendKeyArgShort | SendKeyArgMiddle | SendKeyArgFull;
type ShortcutKey = keyof typeof SHORTCUT_TO_KEYSYM;
/**
* For some reason QEMU's VNC implementation can't
* properly process symbols like «!» and we need to
* emulate shift presses. Partly related link:
* lists.gnu.org/archive/html/qemu-devel/2012-03/msg01109.html
*/
// prettier-ignore
const SHIFTED_KEYSYMS = [
// Shifted 0-9: ) ! @ # $ % ^ & * (
0x29, 0x21, 0x40, 0x23, 0x24, 0x25, 0x5e, 0x26, 0x2a, 0x28,
// Other shifted keys: + < _ > ? ~ { | } "
0x3a, 0x2b, 0x3c, 0x5f, 0x3e, 0x3f, 0x7e, 0x7b, 0x7c, 0x7d, 0x22,
];
// https://github.com/novnc/noVNC/blob/master/core/input/keysym.js
const SHORTCUT_TO_KEYSYM = {
ctrl: NovncKeysym.XK_Control_L,
shift: NovncKeysym.XK_Shift_L,
alt: NovncKeysym.XK_Alt_L,
delete: NovncKeysym.XK_Delete,
sysrq: NovncKeysym.XK_Sys_Req,
windows: NovncKeysym.XK_Super_L,
tab: NovncKeysym.XK_Tab,
f1: NovncKeysym.XK_F1,
f24: NovncKeysym.XK_F24,
enter: NovncKeysym.XK_Return,
};
export class NovncInputSubject extends Subject<SendKeyArgFull> {
protected connectedClients: NovncClient[] = [];
protected shiftKey = new NovncKeyboardKey('Shift', 'ShiftLeft', 'modifier');
protected ctrlKey = new NovncKeyboardKey('Control', 'ControlLeft', 'modifier');
protected altKey = new NovncKeyboardKey('Alt', 'AltLeft', 'modifier');
protected metaKey = new NovncKeyboardKey('Meta', 'MetaLeft', 'modifier');
constructor(protected options: { wrapText?: boolean } = {}) {
super();
}
next(key?: SendKeyArgsAll): void {
if (!key) return;
if (key.length === 1) {
super.next([...key, null, true]);
super.next([...key, null, false]);
return;
}
if (key.length === 2) {
super.next([...key, true]);
super.next([...key, false]);
return;
}
super.next(key);
return;
}
nextShiftKey(down?: boolean) {
const { keysym, code } = this.shiftKey;
if (isNil(down) || down === true) this.next([keysym, code, true]);
if (isNil(down) || down === false) this.next([keysym, code, false]);
}
nextKeyCode(keyCode: number) {
const shiftPressed = SHIFTED_KEYSYMS.includes(keyCode);
if (shiftPressed) this.nextShiftKey(true);
switch (keyCode) {
case 0x0a:
this.next([NovncKeysym.XK_Return]);
break;
default:
this.next([keyCode]);
}
if (shiftPressed) this.nextShiftKey(false);
}
nextKeyboardKey(keyboardKey: NovncKeyboardKey) {
const { keysym, code, shiftKey } = keyboardKey;
if (shiftKey) this.nextShiftKey(true);
this.next([keysym, code, true]);
this.next([keysym, code, false]);
if (shiftKey) this.nextShiftKey(false);
}
nextChar(char: string) {
const keyboardKey = NovncKeyboardLayout.get(char);
if (keyboardKey) return this.nextKeyboardKey(keyboardKey);
return this.nextKeyCode(char.charCodeAt(0));
}
nextText(text: string = '') {
// if (this.options.wrapText)
// this.nextCharCodes(STATE_KEYSYMS);
// this.nextModifierKey(this.ctrlKey, false);
// this.nextModifierKey(this.metaKey, false);
const chars = text.split('');
for (const char of chars) this.nextChar(char);
}
nextShortcut(shortcut: ShortcutKey[] = []) {
const reverse = shortcut.slice(0).reverse();
for (const key of shortcut) this.next([this.shortcutToKeysym(key), null, true]);
for (const key of reverse) this.next([this.shortcutToKeysym(key), null, false]);
}
nextShortcutState(shortcut: ShortcutKey[] = [], pressed: boolean) {
for (const key of shortcut) this.next([this.shortcutToKeysym(key), null, pressed]);
}
private shortcutToKeysym(key: ShortcutKey) {
return SHORTCUT_TO_KEYSYM[key];
}
/**
* We need to split sending keys into ~100 symbols
* chunks because QEMU's VNC implementation lose keys
* if we sending them too fast (OPENSTACK-930).
* @param input
* @returns
*/
asInputObservable(chunkSize = 20, chunkTimeout = 8) {
return this.asObservable().pipe(
bufferTime(chunkTimeout),
switchMap((line) => from(line).pipe(bufferCount(chunkSize))),
concatMap((chunk) => of(chunk).pipe(delay(chunkTimeout))),
concatAll()
);
}
}
import { chain } from 'lodash';
import { NovncInputUtils } from './novnc-core';
/**
* Базовая раскладка получена следующим образом:
* `navigator.keyboard.getLayoutMap().then(x => Array.from(x.entries()))`
*
* https://developer.mozilla.org/en-US/docs/Web/API/Keyboard_API
* https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
*/
const normalCodeKeyPairs = [
['KeyE', 'e'],
['KeyD', 'd'],
['Minus', '-'],
['KeyH', 'h'],
['KeyZ', 'z'],
['Equal', '='],
['KeyN', 'n'],
['KeyP', 'p'],
['BracketRight', ']'],
['BracketLeft', '['],
['Digit8', '8'],
['Digit9', '9'],
['KeyS', 's'],
['Semicolon', ';'],
['Digit5', '5'],
['KeyQ', 'q'],
['KeyO', 'o'],
['Period', '.'],
['Digit6', '6'],
['KeyV', 'v'],
['Digit3', '3'],
['KeyL', 'l'],
['Backquote', '`'],
['KeyG', 'g'],
['KeyJ', 'j'],
['KeyT', 't'],
['Quote', "'"],
['KeyY', 'y'],
['IntlBackslash', '\\'],
['KeyR', 'r'],
['Backslash', '\\'],
['KeyU', 'u'],
['KeyK', 'k'],
['Slash', '/'],
['KeyF', 'f'],
['KeyI', 'i'],
['KeyX', 'x'],
['KeyA', 'a'],
['Digit2', '2'],
['Digit7', '7'],
['KeyM', 'm'],
['Digit4', '4'],
['KeyW', 'w'],
['Digit1', '1'],
['Digit0', '0'],
['KeyB', 'b'],
['KeyC', 'c'],
['Comma', ','],
];
/**
* Символы с раскладками из библиотеки [`Mottie/Keyboard`](https://github.com/Mottie/Keyboard)
* https://github.com/Mottie/Keyboard/blob/master/layouts/_layout_template.js
* https://github.com/Mottie/Keyboard/blob/master/layouts/russian.js
*/
type KeyboardLayoutType = 'normal' | 'shift' | 'alt' | 'alt-shift' | 'modifier';
type KeyboardLayoutMap = Partial<Record<KeyboardLayoutType, string[]>>;
const normalKeyLayout = [
'` 1 2 3 4 5 6 7 8 9 0 - = {bksp}',
'{tab} q w e r t y u i o p [ ] \\',
"a s d f g h j k l ; ' {enter}",
'{shift} z x c v b n m , . / {shift}',
'{accept} {alt} {space} {alt} {cancel}',
];
const englishLayout: KeyboardLayoutMap = {
normal: [
'` 1 2 3 4 5 6 7 8 9 0 - = {bksp}',
'{tab} q w e r t y u i o p [ ] \\',
"a s d f g h j k l ; ' {enter}",
'{shift} z x c v b n m , . / {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
shift: [
'~ ! @ # $ % ^ & * ( ) _ + {bksp}',
'{tab} Q W E R T Y U I O P { } |',
'A S D F G H J K L : " {enter}',
'{shift} Z X C V B N M < > ? {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
alt: [
'~ \u00a1 \u00b2 \u00b3 \u00a4 \u20ac \u00bc \u00bd \u00be \u2018 \u2019 \u00a5 \u00d7 {bksp}',
'{tab} \u00e4 \u00e5 \u00e9 \u00ae \u00fe \u00fc \u00fa \u00ed \u00f3 \u00f6 \u00ab \u00bb \u00ac',
'\u00e1 \u00df \u00f0 f g h j k \u00f8 \u00b6 \u00b4 {enter}',
'{shift} \u00e6 x \u00a9 v b \u00f1 \u00b5 \u00e7 > \u00bf {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
'alt-shift': [
'~ \u00b9 \u00b2 \u00b3 \u00a3 \u20ac \u00bc \u00bd \u00be \u2018 \u2019 \u00a5 \u00f7 {bksp}',
'{tab} \u00c4 \u00c5 \u00c9 \u00ae \u00de \u00dc \u00da \u00cd \u00d3 \u00d6 \u00ab \u00bb \u00a6',
'\u00c4 \u00a7 \u00d0 F G H J K \u00d8 \u00b0 \u00a8 {enter}',
'{shift} \u00c6 X \u00a2 V B \u00d1 \u00b5 \u00c7 . \u00bf {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
};
const russianLayout: KeyboardLayoutMap = {
normal: [
'` 1 2 3 4 5 6 7 8 9 0 - = {bksp}',
'{tab} q w e r t y u i o p [ ] \\',
"a s d f g h j k l ; ' {enter}",
'{shift} z x c v b n m , . / {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
shift: [
'~ ! @ # $ % ^ & * ( ) _ + {bksp}',
'{tab} Q W E R T Y U I O P { } |',
'A S D F G H J K L : " {enter}',
'{shift} Z X C V B N M < > ? {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
alt: [
'\u0451 1 2 3 4 5 6 7 8 9 0 - = {bksp}',
'{tab} \u0439 \u0446 \u0443 \u043a \u0435 \u043d \u0433 \u0448 \u0449 \u0437 \u0445 \u044a \\',
'\u0444 \u044b \u0432 \u0430 \u043f \u0440 \u043e \u043b \u0434 \u0436 \u044d {enter}',
'{shift} \u044f \u0447 \u0441 \u043c \u0438 \u0442 \u044c \u0431 \u044e . {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
'alt-shift': [
'\u0401 ! " \u2116 ; \u20ac : ? * ( ) _ + {bksp}',
'{tab} \u0419 \u0426 \u0423 \u041a \u0415 \u041d \u0413 \u0428 \u0429 \u0417 \u0425 \u042a /',
'\u0424 \u042b \u0412 \u0410 \u041f \u0420 \u041e \u041b \u0414 \u0416 \u042d {enter}',
'{shift} \u042f \u0427 \u0421 \u041c \u0418 \u0422 \u042c \u0411 \u042e , {shift}',
'{accept} {alt} {space} {alt} {cancel}',
],
};
const getLayoutPairs = (layout: string[]) => {
return layout
.join(' ')
.split(' ')
.map((key, index) => [key, index]);
};
const getKeyToIndexMap = (layout: string[]) => {
const keyToIndexPairs = getLayoutPairs(layout);
return Object.fromEntries(keyToIndexPairs);
};
const getIndexToKeyMap = (layout: string[]) => {
const indexToKeyPairs = getLayoutPairs(layout).map(([key, index]) => [index, key]);
return Object.fromEntries(indexToKeyPairs);
};
const parseKeyboardLayouts = (lang: string, layouts: KeyboardLayoutMap) => {
return Object.entries(layouts).map(([type, layout]) => {
const indexToKeyMap = getIndexToKeyMap(layout);
return { lang, type: type as KeyboardLayoutType, indexToKeyMap };
});
};
const normalKeyToIndexMap = getKeyToIndexMap(normalKeyLayout);
const parsedLayouts = [
...parseKeyboardLayouts('en', englishLayout),
...parseKeyboardLayouts('ru', russianLayout),
];
type KeyboardEventType = 'keydown' | 'keyup';
export class NovncKeyboardKey {
protected static readonly modifiersMap = {
shift: { key: 'Shift', code: 'ShiftLeft', location: 1, keyCode: 16, which: 16 },
ctrl: { key: 'Control', code: 'ControlLeft', location: 0, keyCode: 17, which: 17 },
alt: { key: 'Alt', code: 'AltLeft', location: 1, keyCode: 18, which: 18 },
meta: { key: 'Meta', code: 'MetaLeft', location: 1, keyCode: 91, which: 91 },
} as const;
public readonly altKey = ['alt', 'alt-shift'].includes(this.type);
public readonly shiftKey = ['alt-shift', 'shift'].includes(this.type);
public readonly ctrlKey = false;
public readonly metaKey = false;
public readonly weight = { normal: 0, shift: 1, alt: 2, 'alt-shift': 3, modifier: 4 }[this.type];
protected readonly eventOptions = {
key: this.key,
code: this.code,
location: KeyboardEvent.DOM_KEY_LOCATION_STANDARD,
altKey: this.altKey,
shiftKey: this.shiftKey,
ctrlKey: this.ctrlKey,
};
public readonly keysym = NovncInputUtils.getKeysym(this.eventOptions);
constructor(
public readonly key: string,
public readonly code: string,
public readonly type: KeyboardLayoutType,
public readonly options?: Record<string, unknown>
) {}
protected getModifierEvent(key: 'shift' | 'ctrl' | 'alt' | 'meta', type: KeyboardEventType) {
const base = NovncKeyboardKey.modifiersMap[key];
const altKey = key === 'alt' ? type === 'keydown' : this.altKey;
const ctrlKey = key === 'ctrl' ? type === 'keydown' : false;
const shiftKey = key === 'shift' ? type === 'keydown' : false;
const metaKey = key === 'meta' ? type === 'keydown' : false;
return new KeyboardEvent(type, { ...base, altKey, ctrlKey, shiftKey, metaKey });
}
public getShiftEvent(type: KeyboardEventType) {
return this.getModifierEvent('shift', type);
}
public getCtrlEvent(type: KeyboardEventType) {
return this.getModifierEvent('ctrl', type);
}
public getAltEvent(type: KeyboardEventType) {
return this.getModifierEvent('alt', type);
}
public getMetaEvent(type: KeyboardEventType) {
return this.getModifierEvent('meta', type);
}
public getKeyEvent(type: KeyboardEventType) {
return new KeyboardEvent(type, this.eventOptions);
}
public getEventSequence() {
const events: KeyboardEvent[] = [];
if (this.altKey) events.push(this.getAltEvent('keydown'));
if (this.shiftKey) events.push(this.getShiftEvent('keydown'));
events.push(this.getKeyEvent('keydown'));
events.push(this.getKeyEvent('keyup'));
if (this.shiftKey) events.push(this.getShiftEvent('keyup'));
if (this.altKey) events.push(this.getAltEvent('keyup'));
return events;
}
}
const keyboardLayoutPairs = chain(normalCodeKeyPairs)
.flatMap(([code, defaultKey]) => {
const index = normalKeyToIndexMap[defaultKey];
return parsedLayouts.map(({ lang, type, indexToKeyMap }) => {
const key = indexToKeyMap[index];
const options = { lang, defaultKey, index };
const keyboardKey = new NovncKeyboardKey(key, code, type, options);
return keyboardKey;
});
})
.orderBy((key) => key.weight)
.uniqBy((key) => key.key)
.map((key) => [key.key, key] as [string, NovncKeyboardKey])
.value();
export const NovncKeyboardLayout = new Map(keyboardLayoutPairs);
import { from, fromEvent, of, pipe } from 'rxjs';
import { Observable, Subscriber } from 'rxjs';
import { map, mergeMap, share, startWith, switchMap } from 'rxjs/operators';
import { takeUntil, filter } from 'rxjs/operators';
import { NovncClient, NovncOptions, NovncEventType, NovncBrowserUtils } from './novnc-core';
/**
* Создать NoVNC клиент.
* @param element
* @param url
* @param options
* @returns
*/
export const createNovncClient = (element: Element, url?: string, options?: NovncOptions) => {
return new Observable<NovncClient>((subscriber) => {
if (!url) throw new Error('Url is undefined');
const state = { disconnected: false };
const complete = () => {
state.disconnected = true;
subscriber.complete();
};
const unsubscribe = () => {
if (!state.disconnected) {
state.disconnected = true;
try { client.disconnect(); } catch (e) { /* */ } // prettier-ignore
}
};
const client = new NovncClient(element, url, options);
subscriber.next(client);
subscriber.add(fromEvent(client, 'disconnect').subscribe(complete));
subscriber.add({ unsubscribe });
return subscriber;
});
};
/**
* Подписаться на события NoVNC клиента.
* @param client
* @returns
*/
export const listenNovncEvents = () => {
const eventTypeNames: NovncEventType[] = [
'bell',
'capabilities',
'clipboard',
'connect',
'credentialsrequired',
'desktopname',
'disconnect',
'securityfailure',
];
return switchMap((client: NovncClient) => {
return from(eventTypeNames).pipe(
mergeMap((type) => fromEvent(client, type)),
takeUntil(fromEvent(client, 'disconnected')),
share()
);
});
};
export const listenNovncStatus = () => {
type Status = 'loading' | 'initializing' | 'connecting' | 'connected' | 'disconnected';
const getStatus = switchMap(
(client: NovncClient): Observable<Status> => {
return of(client).pipe(
listenNovncEvents(),
map((event): Status | undefined => {
if (event.type === 'desktopname') return 'connecting';
if (event.type === 'connect') return 'connected';
if (event.type === 'disconnect') return 'disconnected';
return undefined;
}),
filter(<T>(status: T): status is NonNullable<T> => !!status),
startWith('initializing' as Status),
takeUntil(fromEvent(client, 'disconnected')),
share()
);
}
);
return pipe(getStatus, startWith('loading' as Status));
};
/**
* Подписаться на событие вставки из буфера обмена.
* @param client
* @param target
* @returns
*/
export const listenNovncClipboardPaste = () => {
const listenDomEvents = (client: NovncClient, subscriber: Subscriber<string>) => {
const body = client._target?.closest('body');
const canvas = client._target?.querySelector('canvas');
if (!canvas || !body) return false;
let wasForceBlurred: KeyboardEvent | null = null;
const onDocumentPaste = (event: ClipboardEvent) => {
if (!wasForceBlurred) return;
if (!event.clipboardData) return;
canvas.dispatchEvent(new KeyboardEvent('keyup', wasForceBlurred));
subscriber.next(event.clipboardData?.getData('Text'));
};
const onDocumentKey = (event: KeyboardEvent) => {
if (!wasForceBlurred) return;
if (event.code === 'KeyV') return;
canvas.dispatchEvent(new KeyboardEvent(event.type, event));
client.focus();
};
const onCanvasKeydown = (event: KeyboardEvent) => {
const isMac = NovncBrowserUtils.isMac() || NovncBrowserUtils.isIOS();
if (isMac && !event.metaKey) return;
if (!isMac && !event.ctrlKey) return;
client.blur();
wasForceBlurred = event;
};
const onCanvasFocus = () => {
wasForceBlurred = null;
};
subscriber.add(fromEvent<ClipboardEvent>(body, 'paste').subscribe(onDocumentPaste));
subscriber.add(fromEvent<KeyboardEvent>(body, 'keydown').subscribe(onDocumentKey));
subscriber.add(fromEvent<KeyboardEvent>(body, 'keyup').subscribe(onDocumentKey));
subscriber.add(fromEvent<KeyboardEvent>(canvas, 'keydown').subscribe(onCanvasKeydown));
subscriber.add(fromEvent<FocusEvent>(canvas, 'focus').subscribe(onCanvasFocus));
return true;
};
const listenClient = (client: NovncClient) =>
new Observable<string>((subscriber) => {
const start$ = fromEvent(client, 'connect');
const finish$ = fromEvent(client, 'disconnect');
if (!listenDomEvents(client, subscriber)) {
subscriber.add(start$.subscribe(() => listenDomEvents(client, subscriber)));
}
subscriber.add(finish$.subscribe(() => () => subscriber.complete()));
return subscriber;
});
return switchMap((client: NovncClient) => listenClient(client).pipe(share()));
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment