backend: Port to superqemu
This commit is contained in:
parent
7d5b7bb9d4
commit
203e8c258c
BIN
.yarn/install-state.gz
Normal file
BIN
.yarn/install-state.gz
Normal file
Binary file not shown.
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
|
@ -3,12 +3,9 @@
|
|||
socket.computer, except not powered by socket.io anymore, and with many less bugs. This monorepo builds
|
||||
|
||||
- The backend
|
||||
- A QEMU VM runner package (feel free to steal it)
|
||||
- Shared components
|
||||
- The webapp (TODO)
|
||||
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
|
|
|
@ -1,35 +1,29 @@
|
|||
{
|
||||
"name": "@socketcomputer/backend",
|
||||
"version": "1.0.0",
|
||||
"private": "true",
|
||||
"description": "socket 2.0 backend",
|
||||
|
||||
"type": "module",
|
||||
|
||||
"scripts": {
|
||||
"build": "parcel build src/index.ts --target node"
|
||||
},
|
||||
|
||||
"author": "modeco80",
|
||||
"license": "MIT",
|
||||
|
||||
"targets": {
|
||||
"node":{
|
||||
"context": "node",
|
||||
"outputFormat": "esmodule"
|
||||
}
|
||||
"node": {
|
||||
"context": "node",
|
||||
"outputFormat": "esmodule"
|
||||
}
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@computernewb/superqemu": "^0.1.0",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@socketcomputer/qemu": "*",
|
||||
"@socketcomputer/shared": "*",
|
||||
"canvas": "^2.11.2",
|
||||
"fastify": "^4.26.2",
|
||||
"mnemonist": "^0.39.8",
|
||||
"canvas": "^2.11.2"
|
||||
"mnemonist": "^0.39.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"parcel": "^2.12.0",
|
||||
"@types/ws": "^8.5.10"
|
||||
"@types/ws": "^8.5.10",
|
||||
"parcel": "^2.12.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// which are standardized across all crusttest slots.
|
||||
// (This file has been bastardized for socket2)
|
||||
|
||||
import { QemuVmDefinition } from '@socketcomputer/qemu';
|
||||
import { QemuVmDefinition } from '@computernewb/superqemu';
|
||||
|
||||
const kQemuPath = '/srv/collabvm/qemu/bin/qemu-system-x86_64';
|
||||
|
||||
|
@ -63,6 +63,7 @@ export function Slot_PCDef(
|
|||
|
||||
return {
|
||||
id: 'socketvm1',
|
||||
command: qCommand
|
||||
command: qCommand.join(' '),
|
||||
snapshot: true
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { QemuVmDefinition, QemuVM, setSnapshot } from '@socketcomputer/qemu';
|
||||
import { QemuVmDefinition, QemuVM } from '@computernewb/superqemu';
|
||||
import { Slot_PCDef } from './SlotQemuDefs.js';
|
||||
|
||||
import { FastifyInstance, fastify, FastifyRequest } from 'fastify';
|
||||
|
@ -6,6 +6,7 @@ import * as fastifyWebsocket from '@fastify/websocket';
|
|||
|
||||
import { SocketVM } from './SocketVM.js';
|
||||
import { VMUser } from './VMUser.js';
|
||||
import pino from 'pino';
|
||||
|
||||
// CONFIG types (not used yet)
|
||||
export type SocketComputerConfig_VM = {
|
||||
|
@ -26,6 +27,10 @@ export class SocketComputerServer {
|
|||
exposeHeadRoutes: false
|
||||
});
|
||||
|
||||
private logger = pino({
|
||||
name: "Sc2Server"
|
||||
});
|
||||
|
||||
Init() {
|
||||
this.fastify.register(fastifyWebsocket.default);
|
||||
this.fastify.register(async (app, _) => this.Routes(app), {});
|
||||
|
@ -33,7 +38,7 @@ export class SocketComputerServer {
|
|||
|
||||
async Listen() {
|
||||
try {
|
||||
console.log('Backend starting...');
|
||||
this.logger.info('Backend starting...');
|
||||
|
||||
// create and start teh VMxorz!!!!
|
||||
await this.InitVM();
|
||||
|
@ -43,6 +48,7 @@ export class SocketComputerServer {
|
|||
port: 4050
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error listening');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +62,6 @@ export class SocketComputerServer {
|
|||
this.vm = new SocketVM(new QemuVM(slotDef));
|
||||
|
||||
// Boot it up
|
||||
setSnapshot(true);
|
||||
await this.vm.Start();
|
||||
}
|
||||
|
||||
|
@ -75,6 +80,7 @@ export class SocketComputerServer {
|
|||
return;
|
||||
}
|
||||
|
||||
|
||||
self.vm?.AddUser(new VMUser(connection, address));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { TurnQueue, UserTimeTuple, kTurnTimeSeconds } from './TurnQueue.js';
|
||||
import { VMUser } from './VMUser.js';
|
||||
import { QemuDisplay, QemuVM, VMState } from '@socketcomputer/qemu';
|
||||
import { QemuDisplay, QemuVM, VMState } from '@computernewb/superqemu';
|
||||
|
||||
import { ExtendableTimer } from './ExtendableTimer.js';
|
||||
import { kMaxUserNameLength } from '@socketcomputer/shared';
|
||||
|
||||
import * as Shared from '@socketcomputer/shared';
|
||||
import { Canvas } from 'canvas';
|
||||
import pino from 'pino';
|
||||
|
||||
// for the maximum socket.io experience
|
||||
const kCanvasJpegQuality = 0.25;
|
||||
|
@ -24,18 +25,22 @@ export class SocketVM extends EventEmitter {
|
|||
private users: Array<VMUser> = [];
|
||||
private queue: TurnQueue = new TurnQueue();
|
||||
|
||||
private logger = pino({
|
||||
name: 'Sc2VM'
|
||||
});
|
||||
|
||||
constructor(vm: QemuVM) {
|
||||
super();
|
||||
this.vm = vm;
|
||||
|
||||
this.timer.on('expired', async () => {
|
||||
// bye bye!
|
||||
console.log(`[SocketVM] VM timer expired, resetting..`);
|
||||
this.logger.info(`[SocketVM] VM timer expired, resetting..`);
|
||||
await this.vm.Stop();
|
||||
});
|
||||
|
||||
this.timer.on('expiry-near', async () => {
|
||||
console.log(`[SocketVM] VM timer expires in 1 minute.`);
|
||||
this.logger.info(`[SocketVM] VM timer expires in 1 minute.`);
|
||||
});
|
||||
|
||||
this.queue.on('turnQueue', (arr: Array<UserTimeTuple>) => {
|
||||
|
@ -97,7 +102,7 @@ export class SocketVM extends EventEmitter {
|
|||
user.username = VMUser.GenerateName();
|
||||
user.vm = this;
|
||||
|
||||
console.log(`[SocketVM] ${user.username} (IP ${user.address}) joined`);
|
||||
this.logger.info({user: user.username, ip: user.address}, 'User joined');
|
||||
|
||||
await this.SendInitialScreen(user);
|
||||
|
||||
|
@ -129,7 +134,7 @@ export class SocketVM extends EventEmitter {
|
|||
}
|
||||
|
||||
async RemUser(user: VMUser) {
|
||||
console.log(`[SocketVM] ${user.username} (IP ${user.address}) left`);
|
||||
this.logger.info({user: user.username, ip: user.address}, 'User left');
|
||||
|
||||
this.users.splice(this.users.indexOf(user), 1);
|
||||
this.queue.TryRemove(user);
|
||||
|
@ -148,7 +153,7 @@ export class SocketVM extends EventEmitter {
|
|||
this.OnMessage(user, await Shared.MessageDecoder.ReadMessage(messageBuffer, false));
|
||||
} catch (err) {
|
||||
// Log the error and close the connection
|
||||
console.log(`Error decoding message, closing connection: (user ${user.username}, ip ${user.address})`, err);
|
||||
this.logger.error({ err: err, user: user.username, ip: user.address }, `Error decoding message, closing connection`);
|
||||
user.connection.close();
|
||||
return;
|
||||
}
|
||||
|
@ -203,8 +208,7 @@ export class SocketVM extends EventEmitter {
|
|||
|
||||
let buffers = this.MakeFullScreenData();
|
||||
|
||||
for (let buffer of buffers)
|
||||
await self.BroadcastBuffer(buffer);
|
||||
for (let buffer of buffers) await self.BroadcastBuffer(buffer);
|
||||
});
|
||||
|
||||
this.display?.on('rect', async (x: number, y: number, rect: ImageData) => {
|
||||
|
|
16
package.json
16
package.json
|
@ -1,28 +1,28 @@
|
|||
{
|
||||
"name": "socketcomputer-repo",
|
||||
"private": "true",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"backend",
|
||||
"qemu",
|
||||
"webapp"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "yarn build:service && yarn build:frontend",
|
||||
"build:service": "npm -w shared run build && npm -w qemu run build && npm -w backend run build",
|
||||
"build": "yarn build:service && yarn build:frontend",
|
||||
"build:service": "npm -w shared run build && npm -w backend run build",
|
||||
"build:frontend": "npm -w shared run build && npm -w webapp run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas": "^2.11.2"
|
||||
"canvas": "^2.11.2",
|
||||
"pino": "^9.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"parcel": "^2.12.0",
|
||||
"@parcel/packager-ts": "2.12.0",
|
||||
"@parcel/transformer-sass": "^2.12.0",
|
||||
"@parcel/transformer-sass": "^2.12.0",
|
||||
"@parcel/transformer-typescript-types": "2.12.0",
|
||||
"@types/node": "^20.12.2",
|
||||
"parcel": "^2.12.0",
|
||||
"prettier": "^3.2.5",
|
||||
"stream-http": "^3.1.0",
|
||||
"typescript": "^5.4.3"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@4.3.1"
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "@socketcomputer/qemu",
|
||||
"version": "1.0.0",
|
||||
"private": "true",
|
||||
"description": "QEMU runtime for socketcomputer backend",
|
||||
"exports": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "parcel build src/index.ts --target node --target types"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"targets": {
|
||||
"types": {},
|
||||
"node": {
|
||||
"context": "node",
|
||||
"isLibrary": true,
|
||||
"outputFormat": "esmodule"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas": "^2.11.2",
|
||||
"execa": "^8.0.1",
|
||||
"split": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"parcel": "^2.12.0",
|
||||
"@types/node": "^20.12.2",
|
||||
"@types/split": "^1.0.5"
|
||||
}
|
||||
}
|
|
@ -1,148 +0,0 @@
|
|||
import { VncClient } from './rfb/client.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Canvas, CanvasRenderingContext2D, createImageData } from 'canvas';
|
||||
import { BatchRects, Size, Rect } from './QemuUtil.js';
|
||||
|
||||
const kQemuFps = 30;
|
||||
|
||||
export type VncRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// events:
|
||||
//
|
||||
// 'resize' -> (w, h) -> done when resize occurs
|
||||
// 'rect' -> (x, y, ImageData) -> framebuffer
|
||||
// 'frame' -> () -> done at end of frame
|
||||
|
||||
export class QemuDisplay extends EventEmitter {
|
||||
private displayVnc = new VncClient({
|
||||
debug: false,
|
||||
fps: kQemuFps,
|
||||
|
||||
encodings: [
|
||||
VncClient.consts.encodings.raw,
|
||||
|
||||
//VncClient.consts.encodings.pseudoQemuAudio,
|
||||
VncClient.consts.encodings.pseudoDesktopSize
|
||||
// For now?
|
||||
//VncClient.consts.encodings.pseudoCursor
|
||||
]
|
||||
});
|
||||
|
||||
private displayCanvas: Canvas = new Canvas(640, 480);
|
||||
private displayCtx: CanvasRenderingContext2D = this.displayCanvas.getContext('2d');
|
||||
|
||||
private vncShouldReconnect: boolean = false;
|
||||
private vncSocketPath: string;
|
||||
|
||||
constructor(socketPath: string) {
|
||||
super();
|
||||
|
||||
this.vncSocketPath = socketPath;
|
||||
|
||||
this.displayVnc.on('connectTimeout', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('authError', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('disconnect', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('closed', () => {
|
||||
this.Reconnect();
|
||||
});
|
||||
|
||||
this.displayVnc.on('firstFrameUpdate', () => {
|
||||
// apparently this library is this good.
|
||||
// at least it's better than the two others which exist.
|
||||
this.displayVnc.changeFps(kQemuFps);
|
||||
this.emit('connected');
|
||||
this.displayCanvas.width = this.displayVnc.clientWidth;
|
||||
this.displayCanvas.height = this.displayVnc.clientHeight;
|
||||
this.emit('resize', this.displayVnc.clientWidth, this.displayVnc.clientHeight);
|
||||
this.emit('rect', 0, 0, this.displayCtx.getImageData(0, 0, this.displayVnc.clientWidth, this.displayVnc.clientHeight));
|
||||
this.emit('frame');
|
||||
});
|
||||
|
||||
this.displayVnc.on('desktopSizeChanged', ({ width, height }) => {
|
||||
this.emit('resize', width, height);
|
||||
this.displayCanvas.width = width;
|
||||
this.displayCanvas.height = height;
|
||||
});
|
||||
|
||||
let rects: Rect[] = [];
|
||||
|
||||
this.displayVnc.on('rectUpdateProcessed', (rect) => {
|
||||
rects.push(rect);
|
||||
});
|
||||
|
||||
this.displayVnc.on('frameUpdated', (fb) => {
|
||||
this.displayCtx.putImageData(createImageData(new Uint8ClampedArray(fb.buffer), this.displayVnc.clientWidth, this.displayVnc.clientHeight), 0, 0);
|
||||
|
||||
// TODO: optimize the rects a bit. using guacamole's cheap method
|
||||
// of just flushing the whole screen if the area of all the updated rects gets too big
|
||||
// might just work.
|
||||
//for (const rect of rects) {
|
||||
// this.emit('rect', rect.x, rect.y, this.displayCtx.getImageData(rect.x, rect.y, rect.width, rect.height));
|
||||
//}
|
||||
|
||||
// cvmts batcher
|
||||
let batched = BatchRects(this.Size(), rects);
|
||||
this.emit('rect', batched.x, batched.y, this.displayCtx.getImageData(batched.x, batched.y, batched.width, batched.height));
|
||||
|
||||
rects = [];
|
||||
|
||||
this.emit('frame');
|
||||
});
|
||||
}
|
||||
|
||||
private Reconnect() {
|
||||
if (this.displayVnc.connected) return;
|
||||
|
||||
if (!this.vncShouldReconnect) return;
|
||||
|
||||
// TODO: this should also give up after a max tries count
|
||||
// if we fail after max tries, emit a event
|
||||
|
||||
this.displayVnc.connect({
|
||||
path: this.vncSocketPath
|
||||
});
|
||||
}
|
||||
|
||||
Connect() {
|
||||
this.vncShouldReconnect = true;
|
||||
this.Reconnect();
|
||||
}
|
||||
|
||||
Disconnect() {
|
||||
this.vncShouldReconnect = false;
|
||||
this.displayVnc.disconnect();
|
||||
}
|
||||
|
||||
GetCanvas() {
|
||||
return this.displayCanvas;
|
||||
}
|
||||
|
||||
Size(): Size {
|
||||
return {
|
||||
width: this.displayVnc.clientWidth,
|
||||
height: this.displayVnc.clientHeight
|
||||
};
|
||||
}
|
||||
|
||||
MouseEvent(x: number, y: number, buttons: number) {
|
||||
this.displayVnc.sendPointerEvent(x, y, buttons);
|
||||
}
|
||||
|
||||
KeyboardEvent(keysym: number, pressed: boolean) {
|
||||
this.displayVnc.sendKeyEvent(keysym, pressed);
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
// QEMU utility functions
|
||||
// most of these are just for randomly generated/temporary files
|
||||
|
||||
import { execa } from 'execa';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
export type Size = { width: number, height: number };
|
||||
export type Rect = {height:number,width:number,x:number,y:number};
|
||||
|
||||
export function BatchRects(size: Size, rects: Array<Rect>): Rect {
|
||||
var mergedX = size.width;
|
||||
var mergedY = size.height;
|
||||
var mergedHeight = 0;
|
||||
var mergedWidth = 0;
|
||||
|
||||
// can't batch these
|
||||
if(rects.length == 0) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
};
|
||||
}
|
||||
|
||||
if(rects.length == 1) {
|
||||
if(rects[0].width == size.width && rects[0].height == size.height) {
|
||||
return rects[0];
|
||||
}
|
||||
}
|
||||
|
||||
rects.forEach((r) => {
|
||||
if (r.x < mergedX) mergedX = r.x;
|
||||
if (r.y < mergedY) mergedY = r.y;
|
||||
});
|
||||
|
||||
rects.forEach(r => {
|
||||
if (((r.height + r.y) - mergedY) > mergedHeight) mergedHeight = (r.height + r.y) - mergedY;
|
||||
if (((r.width + r.x) - mergedX) > mergedWidth) mergedWidth = (r.width + r.x) - mergedX;
|
||||
});
|
||||
|
||||
return {
|
||||
x: mergedX,
|
||||
y: mergedY,
|
||||
width: mergedWidth,
|
||||
height: mergedHeight
|
||||
};
|
||||
}
|
||||
|
||||
// Generates a random unicast/local MAC address.
|
||||
export async function GenMacAddress(): Promise<string> {
|
||||
return new Promise((res, rej) => {
|
||||
crypto.randomBytes(6, (err, buf) => {
|
||||
if (err) rej(err);
|
||||
|
||||
// Modify byte 0 to make this MAC address proper
|
||||
let rawByte0 = buf.readUInt8(0);
|
||||
rawByte0 &= ~0b00000011; // keep most of the bits set from what we got, except for the Unicast and Local bits
|
||||
rawByte0 |= 1 << 1; // Always set the Local bit. Leave the Unicast bit unset.
|
||||
buf.writeUInt8(rawByte0);
|
||||
|
||||
// this makes me wanna cry but it seems to working
|
||||
res(
|
||||
buf
|
||||
.toString('hex')
|
||||
.split(/(.{2})/)
|
||||
.filter((o) => o)
|
||||
.join(':')
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
import { execa, ExecaChildProcess } from 'execa';
|
||||
import { EventEmitter } from 'events';
|
||||
import QmpClient from './QmpClient.js';
|
||||
import { QemuDisplay } from './QemuDisplay.js';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
|
||||
export enum VMState {
|
||||
Stopped,
|
||||
Starting,
|
||||
Started,
|
||||
Stopping
|
||||
}
|
||||
|
||||
export type QemuVmDefinition = {
|
||||
id: string;
|
||||
command: string[];
|
||||
};
|
||||
|
||||
/// Temporary path base (for UNIX sockets/etc.)
|
||||
const kVmTmpPathBase = `/tmp`;
|
||||
|
||||
/// The max amount of times QMP connection is allowed to fail before
|
||||
/// the VM is forcefully stopped.
|
||||
const kMaxFailCount = 5;
|
||||
|
||||
let gVMShouldSnapshot = false;
|
||||
|
||||
async function Sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function setSnapshot(val: boolean) {
|
||||
gVMShouldSnapshot = val;
|
||||
}
|
||||
|
||||
export class QemuVM extends EventEmitter {
|
||||
private state = VMState.Stopped;
|
||||
|
||||
private qmpInstance: QmpClient | null = null;
|
||||
private qmpConnected = false;
|
||||
private qmpFailCount = 0;
|
||||
|
||||
private qemuProcess: ExecaChildProcess | null = null;
|
||||
private qemuRunning = false;
|
||||
|
||||
private display: QemuDisplay | null = null;
|
||||
|
||||
private definition: QemuVmDefinition;
|
||||
private addedCommandShit = false;
|
||||
|
||||
constructor(def: QemuVmDefinition) {
|
||||
super();
|
||||
this.definition = def;
|
||||
}
|
||||
|
||||
async Start() {
|
||||
// Don't start while either trying to start or starting.
|
||||
if (this.state == VMState.Started || this.state == VMState.Starting) return;
|
||||
|
||||
|
||||
var cmd = this.definition.command;
|
||||
|
||||
// build additional command line statements to enable qmp/vnc over unix sockets
|
||||
if(!this.addedCommandShit) {
|
||||
cmd.push('-no-shutdown');
|
||||
if(gVMShouldSnapshot)
|
||||
cmd.push('-snapshot');
|
||||
cmd.push(`-qmp`);
|
||||
cmd.push(`unix:${this.GetQmpPath()},server,nowait`);
|
||||
cmd.push(`-vnc`);
|
||||
cmd.push(`unix:${this.GetVncPath()}`);
|
||||
this.addedCommandShit = true;
|
||||
}
|
||||
|
||||
this.VMLog(`Starting QEMU with command \"${cmd.join(' ')}\"`);
|
||||
await this.StartQemu(cmd);
|
||||
}
|
||||
|
||||
async Stop() {
|
||||
// This is called in certain lifecycle places where we can't safely assert state yet
|
||||
//this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');
|
||||
|
||||
// Start indicating we're stopping, so we don't
|
||||
// erroneously start trying to restart everything
|
||||
// we're going to tear down in this function call.
|
||||
this.SetState(VMState.Stopping);
|
||||
|
||||
// Kill the QEMU process and QMP/display connections if they are running.
|
||||
await this.DisconnectQmp();
|
||||
this.DisconnectDisplay();
|
||||
await this.StopQemu();
|
||||
}
|
||||
|
||||
async Reset() {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
|
||||
|
||||
// let code know the VM is going to reset
|
||||
// N.B: In the crusttest world, a reset simply amounts to a
|
||||
// mean cold reboot of the qemu process basically
|
||||
this.emit('reset');
|
||||
await this.Stop();
|
||||
await Sleep(500);
|
||||
await this.Start();
|
||||
}
|
||||
|
||||
async QmpCommand(command: string, args: any | null): Promise<any> {
|
||||
return await this.qmpInstance?.Execute(command, args);
|
||||
}
|
||||
|
||||
async MonitorCommand(command: string) {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM');
|
||||
return await this.QmpCommand('human-monitor-command', {
|
||||
'command-line': command
|
||||
});
|
||||
}
|
||||
|
||||
async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise<void> {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#ChangeRemovableMedia on a non-started VM');
|
||||
// N.B: if this throws, the code which called this should handle the error accordingly
|
||||
await this.QmpCommand('blockdev-change-medium', {
|
||||
device: deviceName, // techinically deprecated, but I don't feel like figuring out QOM path just for a simple function
|
||||
filename: imagePath
|
||||
});
|
||||
}
|
||||
|
||||
async EjectRemovableMedia(deviceName: string) {
|
||||
this.AssertState(VMState.Started, 'cannot use QemuVM#EjectRemovableMedia on a non-started VM');
|
||||
await this.QmpCommand('eject', {
|
||||
device: deviceName
|
||||
});
|
||||
}
|
||||
|
||||
GetDisplay() {
|
||||
return this.display;
|
||||
}
|
||||
|
||||
/// Private fun bits :)
|
||||
|
||||
private VMLog(...args: any[]) {
|
||||
// TODO: hook this into a logger of some sort
|
||||
console.log(`[QemuVM] [${this.definition.id}] ${args.join('')}`);
|
||||
}
|
||||
|
||||
private AssertState(stateShouldBe: VMState, message: string) {
|
||||
if (this.state !== stateShouldBe) throw new Error(message);
|
||||
}
|
||||
|
||||
private SetState(state: VMState) {
|
||||
this.state = state;
|
||||
this.emit('statechange', this.state);
|
||||
}
|
||||
|
||||
private GetQmpPath() {
|
||||
return `${kVmTmpPathBase}/socket2-${this.definition.id}-ctrl`;
|
||||
}
|
||||
|
||||
private GetVncPath() {
|
||||
return `${kVmTmpPathBase}/socket2-${this.definition.id}-vnc`;
|
||||
}
|
||||
|
||||
private async StartQemu(split: Array<string>) {
|
||||
let self = this;
|
||||
|
||||
this.SetState(VMState.Starting);
|
||||
|
||||
// Start QEMU
|
||||
this.qemuProcess = execa(split[0], split.slice(1));
|
||||
|
||||
this.qemuProcess.on('spawn', async () => {
|
||||
self.qemuRunning = true;
|
||||
await Sleep(500);
|
||||
await self.ConnectQmp();
|
||||
});
|
||||
|
||||
this.qemuProcess.on('exit', async (code) => {
|
||||
self.qemuRunning = false;
|
||||
console.log("qemu process go boom")
|
||||
|
||||
// ?
|
||||
if (self.qmpConnected) {
|
||||
await self.DisconnectQmp();
|
||||
}
|
||||
|
||||
self.DisconnectDisplay();
|
||||
|
||||
if (self.state != VMState.Stopping) {
|
||||
if (code == 0) {
|
||||
await Sleep(500);
|
||||
await self.StartQemu(split);
|
||||
} else {
|
||||
self.VMLog('QEMU exited with a non-zero exit code. This usually means an error in the command line. Stopping VM.');
|
||||
await self.Stop();
|
||||
}
|
||||
} else {
|
||||
this.SetState(VMState.Stopped);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async StopQemu() {
|
||||
if (this.qemuRunning == true) this.qemuProcess?.kill('SIGKILL');
|
||||
}
|
||||
|
||||
private async ConnectQmp() {
|
||||
let self = this;
|
||||
|
||||
if (!this.qmpConnected) {
|
||||
self.qmpInstance = new QmpClient();
|
||||
|
||||
self.qmpInstance.on('close', async () => {
|
||||
self.qmpConnected = false;
|
||||
|
||||
// If we aren't stopping, then we do actually need to care QMP disconnected
|
||||
if (self.state != VMState.Stopping) {
|
||||
if (self.qmpFailCount++ < kMaxFailCount) {
|
||||
this.VMLog(`Failed to connect to QMP ${self.qmpFailCount} times`);
|
||||
await Sleep(500);
|
||||
await self.ConnectQmp();
|
||||
} else {
|
||||
this.VMLog(`Failed to connect to QMP ${self.qmpFailCount} times, giving up`);
|
||||
await self.Stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.qmpInstance.on('event', async (ev) => {
|
||||
switch (ev.event) {
|
||||
// Handle the STOP event sent when using -no-shutdown
|
||||
case 'STOP':
|
||||
await self.qmpInstance?.Execute('system_reset');
|
||||
break;
|
||||
case 'RESET':
|
||||
self.VMLog('got a reset event!');
|
||||
await self.qmpInstance?.Execute('cont');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
self.qmpInstance.on('qmp-ready', async (hadError) => {
|
||||
self.VMLog('QMP ready');
|
||||
|
||||
self.display = new QemuDisplay(self.GetVncPath());
|
||||
self.display.Connect();
|
||||
|
||||
// QMP has been connected so the VM is ready to be considered started
|
||||
self.qmpFailCount = 0;
|
||||
self.qmpConnected = true;
|
||||
self.SetState(VMState.Started);
|
||||
});
|
||||
|
||||
try {
|
||||
await Sleep(500);
|
||||
this.qmpInstance?.ConnectUNIX(this.GetQmpPath());
|
||||
} catch (err) {
|
||||
// just try again
|
||||
await Sleep(500);
|
||||
await this.ConnectQmp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async DisconnectDisplay() {
|
||||
try {
|
||||
this.display?.Disconnect();
|
||||
this.display = null; // disassociate with that display object.
|
||||
|
||||
await unlink(this.GetVncPath());
|
||||
} catch (err) {
|
||||
// oh well lol
|
||||
}
|
||||
}
|
||||
|
||||
private async DisconnectQmp() {
|
||||
if (this.qmpConnected) return;
|
||||
if(this.qmpInstance == null)
|
||||
return;
|
||||
|
||||
this.qmpConnected = false;
|
||||
this.qmpInstance.end();
|
||||
this.qmpInstance = null;
|
||||
try {
|
||||
await unlink(this.GetQmpPath());
|
||||
} catch(err) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,135 +0,0 @@
|
|||
// This was originally based off the contents of the node-qemu-qmp package,
|
||||
// but I've modified it possibly to the point where it could be treated as my own creation.
|
||||
|
||||
import split from 'split';
|
||||
|
||||
import { Socket } from 'net';
|
||||
|
||||
export type QmpCallback = (err: Error | null, res: any | null) => void;
|
||||
|
||||
type QmpCommandEntry = {
|
||||
callback: QmpCallback | null;
|
||||
id: number;
|
||||
};
|
||||
|
||||
// TODO: Instead of the client "Is-A"ing a Socket, this should instead contain/store a Socket,
|
||||
// (preferrably) passed by the user, to use for QMP communications.
|
||||
// The client shouldn't have to know or care about the protocol, and it effectively hackily uses the fact
|
||||
// Socket extends EventEmitter.
|
||||
|
||||
export default class QmpClient extends Socket {
|
||||
public qmpHandshakeData: any;
|
||||
private commandEntries: QmpCommandEntry[] = [];
|
||||
private lastID = 0;
|
||||
|
||||
private ExecuteSync(command: string, args: any | null, callback: QmpCallback | null) {
|
||||
let cmd: QmpCommandEntry = {
|
||||
callback: callback,
|
||||
id: ++this.lastID
|
||||
};
|
||||
|
||||
let qmpOut: any = {
|
||||
execute: command,
|
||||
id: cmd.id
|
||||
};
|
||||
|
||||
if (args) qmpOut['arguments'] = args;
|
||||
|
||||
// Add stuff
|
||||
this.commandEntries.push(cmd);
|
||||
this.write(JSON.stringify(qmpOut));
|
||||
}
|
||||
|
||||
// TODO: Make this function a bit more ergonomic?
|
||||
async Execute(command: string, args: any | null = null): Promise<any> {
|
||||
return new Promise((res, rej) => {
|
||||
this.ExecuteSync(command, args, (err, result) => {
|
||||
if (err) rej(err);
|
||||
res(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Handshake(callback: () => void) {
|
||||
this.write(
|
||||
JSON.stringify({
|
||||
execute: 'qmp_capabilities'
|
||||
})
|
||||
);
|
||||
|
||||
this.once('data', (data) => {
|
||||
// Once QEMU replies to us, the handshake is done.
|
||||
// We do not negotiate anything special.
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
// this can probably be made async
|
||||
private ConnectImpl() {
|
||||
let self = this;
|
||||
|
||||
this.once('connect', () => {
|
||||
this.removeAllListeners('error');
|
||||
});
|
||||
|
||||
this.once('error', (err) => {
|
||||
// just rethrow lol
|
||||
//throw err;
|
||||
|
||||
console.log("you have pants: rules,", err);
|
||||
});
|
||||
|
||||
this.once('data', (data) => {
|
||||
// Handshake QMP with the server.
|
||||
self.qmpHandshakeData = JSON.parse(data.toString('utf8')).QMP;
|
||||
self.Handshake(() => {
|
||||
// Now ready to parse QMP responses/events.
|
||||
self.pipe(split(JSON.parse))
|
||||
.on('data', (json: any) => {
|
||||
if (json == null) return self.end();
|
||||
|
||||
if (json.return || json.error) {
|
||||
// Our handshake has a spurious return because we never assign it an ID,
|
||||
// and it is gathered by this pipe for some reason I'm not quite sure about.
|
||||
// So, just for safety's sake, don't process any return objects which don't have an ID attached to them.
|
||||
if (json.id == null) return;
|
||||
|
||||
let callbackEntry = this.commandEntries.find((entry) => entry.id === json.id);
|
||||
let error: Error | null = json.error ? new Error(json.error.desc) : null;
|
||||
|
||||
// we somehow didn't find a callback entry for this response.
|
||||
// I don't know how. Techinically not an error..., but I guess you're not getting a reponse to whatever causes this to happen
|
||||
if (callbackEntry == null) return;
|
||||
|
||||
if (callbackEntry?.callback) callbackEntry.callback(error, json.return);
|
||||
|
||||
// Remove the completed callback entry.
|
||||
this.commandEntries.slice(this.commandEntries.indexOf(callbackEntry));
|
||||
} else if (json.event) {
|
||||
this.emit('event', json);
|
||||
}
|
||||
})
|
||||
.on('error', () => {
|
||||
// Give up.
|
||||
return self.end();
|
||||
});
|
||||
this.emit('qmp-ready');
|
||||
});
|
||||
});
|
||||
|
||||
this.once('close', () => {
|
||||
this.end();
|
||||
this.removeAllListeners('data'); // wow. good job bud. cool memory leak
|
||||
});
|
||||
}
|
||||
|
||||
Connect(host: string, port: number) {
|
||||
super.connect(port, host);
|
||||
this.ConnectImpl();
|
||||
}
|
||||
|
||||
ConnectUNIX(path: string) {
|
||||
super.connect(path);
|
||||
this.ConnectImpl();
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export * from './QemuDisplay.js';
|
||||
export * from './QemuUtil.js';
|
||||
export * from './QemuVM.js';
|
|
@ -1,21 +0,0 @@
|
|||
Copyright 2021 Filipe Calaça Barbosa
|
||||
Copyright 2022 dither
|
||||
Copyright 2023-2024 Lily Tsuru/modeco80 <lily.modeco80@protonmail.ch>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -1,11 +0,0 @@
|
|||
# Notice
|
||||
|
||||
The source here was originally taken from a fork of [vnc-rfb-client](https://github.com/ayunami2000/vnc-rfb-client) made for the LucidVM project, available [here](https://github.com/lucidvm/rfb).
|
||||
|
||||
It has been grossly modified for the usecases for the socket2 backend, but is more than likely usable to other clients.
|
||||
|
||||
- converted to (strict) TypeScript; my initial port beforehand was not strict and it Sucked
|
||||
- Also, actually use interfaces, and reduce the amount of code duplicated significantly in the rect decoders.
|
||||
- all modules rewritten to use ESM
|
||||
- some noisy debug prints removed
|
||||
- (some, very tiny) code cleanup
|
|
@ -1,919 +0,0 @@
|
|||
|
||||
import { IRectDecoder } from './decoders/decoder.js';
|
||||
import { HextileDecoder } from './decoders/hextile.js';
|
||||
import { RawDecoder } from './decoders/raw.js';
|
||||
import { ZrleDecoder } from './decoders/zrle.js';
|
||||
// import { TightDecoder } from "./decoders/tight.js";
|
||||
import { CopyRectDecoder } from './decoders/copyrect.js';
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { consts } from './constants.js';
|
||||
|
||||
import * as net from 'node:net';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
import { SocketBuffer } from './socketbuffer.js';
|
||||
|
||||
import { VncRectangle, Color3, PixelFormat, Cursor } from './types.js';
|
||||
|
||||
export class VncClient extends EventEmitter {
|
||||
// These are in no particular order.
|
||||
|
||||
public debug: boolean = false;
|
||||
|
||||
private _connected: boolean = false;
|
||||
private _authenticated: boolean = false;
|
||||
private _version: string = "";
|
||||
private _password: string = "";
|
||||
|
||||
private _audioChannels: number = 2;
|
||||
private _audioFrequency: number = 22050;
|
||||
|
||||
private _rects: number = 0;
|
||||
|
||||
private _decoders: Array<IRectDecoder> = [];
|
||||
|
||||
private _fps: number;
|
||||
private _timerInterval: number;
|
||||
private _timerPointer : NodeJS.Timeout|null = null;
|
||||
|
||||
public fb: Buffer = Buffer.from([]);
|
||||
|
||||
private _handshaked: boolean = false;
|
||||
private _waitingServerInit: boolean = false;
|
||||
private _expectingChallenge: boolean = false;
|
||||
private _challengeResponseSent: boolean = false;
|
||||
|
||||
private _set8BitColor: boolean = false;
|
||||
private _frameBufferReady = false;
|
||||
private _firstFrameReceived = false;
|
||||
private _processingFrame = false;
|
||||
|
||||
private _relativePointer: boolean = false;
|
||||
|
||||
public bigEndianFlag: boolean = false;
|
||||
|
||||
public clientWidth: number = 0;
|
||||
public clientHeight: number = 0;
|
||||
public clientName: string = "";
|
||||
|
||||
public pixelFormat: PixelFormat = {
|
||||
bitsPerPixel: 0,
|
||||
depth: 0,
|
||||
bigEndianFlag: 0,
|
||||
trueColorFlag: 0,
|
||||
redMax: 0,
|
||||
greenMax: 0,
|
||||
blueMax: 0,
|
||||
redShift: 0,
|
||||
greenShift: 0,
|
||||
blueShift: 0
|
||||
};
|
||||
|
||||
private _colorMap: Color3[] = [];
|
||||
private _audioData: Buffer = Buffer.from([]);
|
||||
|
||||
private _cursor: Cursor = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
cursorPixels: null,
|
||||
bitmask: null,
|
||||
posX: 0,
|
||||
posY: 0
|
||||
};
|
||||
|
||||
public encodings: number[];
|
||||
|
||||
private _connection: net.Socket|null = null;
|
||||
private _socketBuffer: SocketBuffer;
|
||||
|
||||
static get consts() {
|
||||
return {
|
||||
encodings: consts.encodings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return if client is connected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get connected() {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return if client is authenticated
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get authenticated() {
|
||||
return this._authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return negotiated protocol version
|
||||
* @returns {string}
|
||||
*/
|
||||
get protocolVersion() {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the local port used by the client
|
||||
* @returns {number}
|
||||
*/
|
||||
get localPort() {
|
||||
return this._connection ? this._connection?.localPort : 0;
|
||||
}
|
||||
|
||||
constructor(options: any = { debug: false, fps: 0, encodings: [] }) {
|
||||
super();
|
||||
|
||||
this._socketBuffer = new SocketBuffer();
|
||||
|
||||
this.resetState();
|
||||
this.debug = options.debug || false;
|
||||
this._fps = Number(options.fps) || 0;
|
||||
// Calculate interval to meet configured FPS
|
||||
this._timerInterval = this._fps > 0 ? 1000 / this._fps : 0;
|
||||
|
||||
// Default encodings
|
||||
this.encodings =
|
||||
options.encodings && options.encodings.length
|
||||
? options.encodings
|
||||
: [consts.encodings.copyRect, consts.encodings.zrle, consts.encodings.hextile, consts.encodings.raw, consts.encodings.pseudoDesktopSize];
|
||||
|
||||
this._audioChannels = options.audioChannels || 2;
|
||||
this._audioFrequency = options.audioFrequency || 22050;
|
||||
|
||||
this._rects = 0;
|
||||
|
||||
this._decoders[consts.encodings.raw] = new RawDecoder();
|
||||
// TODO: Implement tight encoding
|
||||
// this._decoders[encodings.tight] = new tightDecoder();
|
||||
this._decoders[consts.encodings.zrle] = new ZrleDecoder();
|
||||
this._decoders[consts.encodings.copyRect] = new CopyRectDecoder();
|
||||
this._decoders[consts.encodings.hextile] = new HextileDecoder();
|
||||
|
||||
if (this._timerInterval) {
|
||||
this._fbTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer used to limit the rate of frame update requests according to configured FPS
|
||||
* @private
|
||||
*/
|
||||
_fbTimer() {
|
||||
this._timerPointer = setTimeout(() => {
|
||||
this._fbTimer();
|
||||
if (this._firstFrameReceived && !this._processingFrame && this._fps > 0) {
|
||||
this.requestFrameUpdate();
|
||||
}
|
||||
}, this._timerInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjuste the configured FPS
|
||||
* @param fps {number} - Number of update requests send by second
|
||||
*/
|
||||
changeFps(fps: number) {
|
||||
if (!Number.isNaN(fps)) {
|
||||
this._fps = Number(fps);
|
||||
this._timerInterval = this._fps > 0 ? 1000 / this._fps : 0;
|
||||
|
||||
if (this._timerPointer && !this._fps) {
|
||||
// If FPS was zeroed stop the timer
|
||||
clearTimeout(this._timerPointer);
|
||||
this._timerPointer = null;
|
||||
} else if (this._fps && !this._timerPointer) {
|
||||
// If FPS was zero and is now set, start the timer
|
||||
this._fbTimer();
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid FPS. Must be a number.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the connection with the VNC server
|
||||
* @param options
|
||||
*/
|
||||
connect(
|
||||
options: any /* = {
|
||||
host: '',
|
||||
password: '',
|
||||
path: '',
|
||||
set8BitColor: false,
|
||||
port: 5900
|
||||
} */
|
||||
) {
|
||||
if (options.password) {
|
||||
this._password = options.password;
|
||||
}
|
||||
|
||||
this._set8BitColor = options.set8BitColor || false;
|
||||
|
||||
if (options.path === null) {
|
||||
if (!options.host) {
|
||||
throw new Error('Host missing.');
|
||||
}
|
||||
this._connection = net.connect(options.port || 5900, options.host);
|
||||
|
||||
// disable nagle's algorithm for TCP
|
||||
this._connection?.setNoDelay();
|
||||
} else {
|
||||
// unix socket. bodged in but oh well
|
||||
this._connection = net.connect(options.path);
|
||||
}
|
||||
|
||||
this._connection?.on('connect', () => {
|
||||
this._connected = true;
|
||||
this.emit('connected');
|
||||
});
|
||||
|
||||
this._connection?.on('close', () => {
|
||||
this.resetState();
|
||||
this.emit('closed');
|
||||
});
|
||||
|
||||
this._connection?.on('timeout', () => {
|
||||
this.emit('connectTimeout');
|
||||
});
|
||||
|
||||
this._connection?.on('error', (err) => {
|
||||
this.emit('connectError', err);
|
||||
});
|
||||
|
||||
this._connection?.on('data', async (data) => {
|
||||
this._socketBuffer.pushData(data);
|
||||
|
||||
if (!this._handshaked) {
|
||||
this._handleHandshake();
|
||||
} else if (this._expectingChallenge) {
|
||||
this._handleAuthChallenge();
|
||||
} else if (this._waitingServerInit) {
|
||||
await this._handleServerInit();
|
||||
} else {
|
||||
await this._handleData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the client
|
||||
*/
|
||||
disconnect() {
|
||||
if (this._connection) {
|
||||
this._connection?.end();
|
||||
this.resetState();
|
||||
this.emit('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the server a frame update
|
||||
* @param full - If the server should send all the frame buffer or just the last changes
|
||||
* @param incremental - Incremental number for not full requests
|
||||
* @param x - X position of the update area desired, usually 0
|
||||
* @param y - Y position of the update area desired, usually 0
|
||||
* @param width - Width of the update area desired, usually client width
|
||||
* @param height - Height of the update area desired, usually client height
|
||||
*/
|
||||
requestFrameUpdate(full = false, incremental = 1, x = 0, y = 0, width = this.clientWidth, height = this.clientHeight) {
|
||||
if ((this._frameBufferReady || full) && this._connection && !this._rects) {
|
||||
// Request data
|
||||
const message = Buffer.alloc(10);
|
||||
message.writeUInt8(3); // Message type
|
||||
message.writeUInt8(full ? 0 : incremental, 1); // Incremental
|
||||
message.writeUInt16BE(x, 2); // X-Position
|
||||
message.writeUInt16BE(y, 4); // Y-Position
|
||||
message.writeUInt16BE(width, 6); // Width
|
||||
message.writeUInt16BE(height, 8); // Height
|
||||
|
||||
this._connection?.write(message);
|
||||
|
||||
this._frameBufferReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle handshake msg
|
||||
* @private
|
||||
*/
|
||||
_handleHandshake() {
|
||||
// Handshake, negotiating protocol version
|
||||
if (this._socketBuffer.toString() === consts.versionString.V3_003) {
|
||||
this._log('Sending 3.3', true);
|
||||
this._connection?.write(consts.versionString.V3_003);
|
||||
this._version = '3.3';
|
||||
} else if (this._socketBuffer.toString() === consts.versionString.V3_007) {
|
||||
this._log('Sending 3.7', true);
|
||||
this._connection?.write(consts.versionString.V3_007);
|
||||
this._version = '3.7';
|
||||
} else if (this._socketBuffer.toString() === consts.versionString.V3_008) {
|
||||
this._log('Sending 3.8', true);
|
||||
this._connection?.write(consts.versionString.V3_008);
|
||||
this._version = '3.8';
|
||||
} else {
|
||||
// Negotiating auth mechanism
|
||||
this._handshaked = true;
|
||||
if (this._socketBuffer.includes(0x02) && this._password) {
|
||||
this._log('Password provided and server support VNC auth. Choosing VNC auth.', true);
|
||||
this._expectingChallenge = true;
|
||||
this._connection?.write(Buffer.from([0x02]));
|
||||
} else if (this._socketBuffer.includes(1)) {
|
||||
this._log('Password not provided or server does not support VNC auth. Trying none.', true);
|
||||
this._connection?.write(Buffer.from([0x01]));
|
||||
if (this._version === '3.7') {
|
||||
this._waitingServerInit = true;
|
||||
} else {
|
||||
this._expectingChallenge = true;
|
||||
this._challengeResponseSent = true;
|
||||
}
|
||||
} else {
|
||||
this._log('Connection error. Msg: ' + this._socketBuffer.toString());
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
this._socketBuffer?.flush(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle VNC auth challenge
|
||||
* @private
|
||||
*/
|
||||
_handleAuthChallenge() {
|
||||
if (this._challengeResponseSent) {
|
||||
// Challenge response already sent. Checking result.
|
||||
|
||||
if (this._socketBuffer.buffer[3] === 0) {
|
||||
// Auth success
|
||||
this._authenticated = true;
|
||||
this.emit('authenticated');
|
||||
this._expectingChallenge = false;
|
||||
this._sendClientInit();
|
||||
} else {
|
||||
// Auth fail
|
||||
this.emit('authError');
|
||||
this.resetState();
|
||||
}
|
||||
} else {
|
||||
const key = Buffer.alloc(8);
|
||||
key.fill(0);
|
||||
key.write(this._password.slice(0, 8));
|
||||
|
||||
this.reverseBits(key);
|
||||
|
||||
const des1 = crypto.createCipheriv('des', key, Buffer.alloc(8));
|
||||
const des2 = crypto.createCipheriv('des', key, Buffer.alloc(8));
|
||||
|
||||
const response = Buffer.alloc(16);
|
||||
|
||||
response.fill(des1.update(this._socketBuffer.buffer.slice(0, 8)), 0, 8);
|
||||
response.fill(des2.update(this._socketBuffer.buffer.slice(8, 16)), 8, 16);
|
||||
|
||||
this._connection?.write(response);
|
||||
this._challengeResponseSent = true;
|
||||
}
|
||||
|
||||
this._socketBuffer.flush(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse bits order of a byte
|
||||
* @param buf - Buffer to be flipped
|
||||
*/
|
||||
reverseBits(buf: Buffer) {
|
||||
for (let x = 0; x < buf.length; x++) {
|
||||
let newByte = 0;
|
||||
newByte += buf[x] & 128 ? 1 : 0;
|
||||
newByte += buf[x] & 64 ? 2 : 0;
|
||||
newByte += buf[x] & 32 ? 4 : 0;
|
||||
newByte += buf[x] & 16 ? 8 : 0;
|
||||
newByte += buf[x] & 8 ? 16 : 0;
|
||||
newByte += buf[x] & 4 ? 32 : 0;
|
||||
newByte += buf[x] & 2 ? 64 : 0;
|
||||
newByte += buf[x] & 1 ? 128 : 0;
|
||||
buf[x] = newByte;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle server init msg
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _handleServerInit() {
|
||||
this._waitingServerInit = false;
|
||||
|
||||
await this._socketBuffer.waitBytes(18);
|
||||
|
||||
this.clientWidth = this._socketBuffer.readUInt16BE();
|
||||
this.clientHeight = this._socketBuffer.readUInt16BE();
|
||||
|
||||
this.pixelFormat.bitsPerPixel = this._socketBuffer.readUInt8();
|
||||
this.pixelFormat.depth = this._socketBuffer.readUInt8();
|
||||
this.pixelFormat.bigEndianFlag = this._socketBuffer.readUInt8();
|
||||
this.pixelFormat.trueColorFlag = this._socketBuffer.readUInt8();
|
||||
this.pixelFormat.redMax = this.bigEndianFlag ? this._socketBuffer.readUInt16BE() : this._socketBuffer.readUInt16LE();
|
||||
this.pixelFormat.greenMax = this.bigEndianFlag ? this._socketBuffer.readUInt16BE() : this._socketBuffer.readUInt16LE();
|
||||
this.pixelFormat.blueMax = this.bigEndianFlag ? this._socketBuffer.readUInt16BE() : this._socketBuffer.readUInt16LE();
|
||||
this.pixelFormat.redShift = this._socketBuffer.readInt8();
|
||||
this.pixelFormat.greenShift = this._socketBuffer.readInt8();
|
||||
this.pixelFormat.blueShift = this._socketBuffer.readInt8();
|
||||
this.updateFbSize();
|
||||
this.clientName = this._socketBuffer.buffer.slice(24).toString();
|
||||
|
||||
this._socketBuffer.flush(false);
|
||||
|
||||
// FIXME: Removed because these are noise
|
||||
//this._log(`Screen size: ${this.clientWidth}x${this.clientHeight}`);
|
||||
//this._log(`Client name: ${this.clientName}`);
|
||||
//this._log(`pixelFormat: ${JSON.stringify(this.pixelFormat)}`);
|
||||
|
||||
if (this._set8BitColor) {
|
||||
//this._log(`8 bit color format requested, only raw encoding is supported.`);
|
||||
this._setPixelFormatToColorMap();
|
||||
}
|
||||
|
||||
this._sendEncodings();
|
||||
|
||||
setTimeout(() => {
|
||||