initial commit

This commit is contained in:
Lily Tsuru 2024-04-02 07:43:54 -04:00
commit 071b531679
45 changed files with 6874 additions and 0 deletions

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
# if this is changed please change it in the .clang-format so that nothing explodes
indent_size = 4

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
**/dist
/package-lock.json
# why don't you put this in the webapp/ project root?
/.parcel-cache
# nvm it does now ok
/webapp/.parcel-cache

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
dist
*.md
*.json

20
.prettierrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": true,
"printWidth": 200,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"useTabs": true,
"vueIndentScriptAndStyle": false
}

20
LICENSE Normal file
View file

@ -0,0 +1,20 @@
Copyright 2023 Lily Tsuru/modeco80 <lily.modeco80@protonmail.ch>
Please see qemu/src/rfb/LICENSE for additional terms.
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.

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# Socket.Computer
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 CrustTest webapp (TODO)
## Building
```bash
$ yarn
$ yarn build:service # Build the service
$ yarn build:frontend # Build the webapp
```
## Hosting
TODO
- Edit `webapp/src/index.ts` to point the websocket URL to an appopiate place
- Build the service and the webapp (tip, see the above section)
- copy `webapp/dist` (excl. `.map` files) to an applicable webroot
- Run the backend, optionally with systemd service things (MAKE SURE TO SET NODE_ENV TO PRODUCTION.) (also proxy it for wss please)
... profit?

24
backend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "@socketcomputer/backend",
"version": "1.0.0",
"private": "true",
"description": "socket 2.0 backend",
"type": "module",
"scripts": {
"build": "tsc"
},
"author": "modeco80",
"license": "MIT",
"dependencies": {
"@fastify/websocket": "^10.0.1",
"@julusian/jpeg-turbo": "^2.1.0",
"@socketcomputer/qemu": "*",
"@socketcomputer/shared": "*",
"fastify": "^4.26.2",
"mnemonist": "^0.39.8",
"canvas": "^2.11.2"
},
"devDependencies": {
"@types/ws": "^8.5.10"
}
}

View file

@ -0,0 +1,46 @@
import { EventEmitter } from 'node:events';
// TODO: Second granualarity. (nvm this is fine for socket2..)
const kMinute = 60 * 1000;
export class ExtendableTimer extends EventEmitter {
private timeout: NodeJS.Timeout;
private iterationcount: number = 0;
private time: number;
constructor(baseTimeMin: number) {
super();
this.time = baseTimeMin;
}
private Arm() {
this.timeout = setTimeout(() => {
this.iterationcount--;
if (this.iterationcount == 1) {
this.emit('expiry-near');
} else if (this.iterationcount == 0) {
return this.emit('expired');
}
this.Arm();
}, kMinute);
}
Start() {
this.iterationcount = this.time;
clearTimeout(this.timeout);
this.Arm();
}
Stop() {
this.iterationcount = 0;
clearTimeout(this.timeout);
this.emit('expired');
}
Extend() {
this.iterationcount = this.time;
clearTimeout(this.timeout);
this.Arm();
}
}

View file

@ -0,0 +1,70 @@
// QEMU definitions. These define the base QEMU command lines,
// which are standardized across all crusttest slots.
// (This file has been bastardized for socket2)
import { QemuVmDefinition } from '@socketcomputer/qemu';
const kQemuPath = '/srv/collabvm/qemu/bin/qemu-system-x86_64';
// the aio model qemu will use. if possible, leave this at 'io_uring'.
const kQemuAio = 'io_uring';
/// Creates a base definition for a VM with PC chipset.
export function Slot_PCDef(
// RAM
ramSize: string,
// Network
netdevOption: string = '-netdev user,id=vm.wan',
netAdapterModel: string = 'rtl8139',
netMac: string = 'c7:4e:c0:5f:2c:7c',
// HDA
hdaIsSsd: boolean = true,
hdImagePath: string,
hdImageFormat: string
): QemuVmDefinition {
let qCommand = [kQemuPath];
let pushOption = (opt: string) => {
qCommand.push(opt.substring(0, opt.indexOf(' ')));
qCommand.push(opt.substring(opt.indexOf(' ') + 1));
};
// boilerplate/chipset
qCommand.push('-nodefaults');
pushOption('-machine pc-i440fx-2.10,accel=kvm,kernel_irqchip=on,hpet=off,acpi=on,usb=on');
pushOption('-rtc base=localtime,clock=vm');
// CPU/RAM
pushOption(`-cpu pentium3`);
pushOption(`-m ${ramSize}`); // unlike LVM we don't really want to prealloc per se..
pushOption(`${netdevOption}`);
pushOption(`-device ${netAdapterModel},id=vm.netadp,netdev=vm.wan,mac=${netMac}`);
pushOption(
`-drive if=none,file=${hdImagePath},cache=writeback,discard=unmap,format=${hdImageFormat},aio=${kQemuAio},id=vm.hda_drive,bps=65000000,bps_max=65000000,iops=1500,iops_max=2000`
);
// prettier-ignore
if (hdaIsSsd)
pushOption(`-device ide-hd,id=vm.hda,rotation_rate=1,drive=vm.hda_drive`);
else
pushOption(`-device ide-hd,id=vm.hda,drive=vm.hda_drive`);
// CD drive
pushOption(`-drive if=none,media=cdrom,aio=${kQemuAio},id=vm.cd`);
pushOption(`-device ide-cd,drive=vm.cd,id=vm.cd_drive`);
pushOption('-audio driver=none,model=ac97');
// VGA + usb tablet
pushOption('-device VGA,id=vm.vga');
pushOption('-device usb-tablet');
return {
id: "socketvm1",
command: qCommand
};
}

View file

@ -0,0 +1,395 @@
import { QemuVmDefinition, QemuDisplay, QemuVM, VMState, setSnapshot, GenMacAddress } from '@socketcomputer/qemu';
import { Slot_PCDef } from './SlotQemuDefs.js';
import { ExtendableTimer } from './ExtendableTimer.js';
import { EventEmitter } from 'node:events';
import * as Shared from '@socketcomputer/shared';
import { Canvas } from 'canvas';
import { FastifyInstance, fastify } from 'fastify';
import * as fastifyWebsocket from '@fastify/websocket';
import { WebSocket } from 'ws';
import Queue from 'mnemonist/queue.js';
class VMUser {
public connection: WebSocket;
public username: string;
private vm: VirtualMachine;
constructor(connection: WebSocket, slot: VirtualMachine) {
this.connection = connection;
this.vm = slot;
this.vm.AddUser(this);
this.connection.on('message', async (data, isBinary) => {
if (!isBinary) this.connection.close(1000);
await this.vm.OnWSMessage(this, data as Buffer);
});
this.connection.on('close', async () => {
console.log('closed');
await this.vm.RemUser(this);
});
}
async SendMessage(messageGenerator: (encoder: Shared.MessageEncoder) => ArrayBuffer) {
await this.SendBuffer(messageGenerator(new Shared.MessageEncoder()));
}
async SendBuffer(buffer: ArrayBuffer): Promise<void> {
return new Promise((res, rej) => {
if (this.connection.readyState !== WebSocket.CLOSED) {
this.connection.send(buffer, (err) => {
res();
});
}
});
}
static GenerateName() {
return `guest${Math.floor(Math.random() * (99999 - 10000) + 10000)}`;
}
}
const kTurnTimeSeconds = 18;
type userAndTime = {
user: VMUser;
// waiting time if this user is not the front.
time: number;
};
// the turn queue. yes this is mostly stolen from cvmts but I make it cleaner by Seperate!!!!!!!
class TurnQueue extends EventEmitter {
private queue: Queue<VMUser> = new Queue<VMUser>();
private turnTime = kTurnTimeSeconds;
private interval: NodeJS.Timeout = null;
constructor() {
super();
}
public CurrentUser(): VMUser {
return this.queue.peek();
}
public TryEnqueue(user: VMUser) {
// Already the current user
if (this.CurrentUser() == user) return;
// Already in the queue
if (this.queue.toArray().indexOf(user) !== -1) return;
this.queue.enqueue(user);
if (this.queue.size == 1) this.nextTurn();
}
private turnInterval() {
this.turnTime--;
if (this.turnTime < 1) {
this.queue.dequeue();
this.nextTurn();
}
}
private nextTurn() {
clearInterval(this.interval);
if (this.queue.size === 0) {
} else {
this.turnTime = kTurnTimeSeconds;
this.interval = setInterval(() => this.turnInterval(), 1000);
}
if (this.queue.size == 1) this.emit('turnQueue', [{ user: this.CurrentUser(), time: kTurnTimeSeconds * 1000 }]);
// removes the front of the quuee
let arr = this.queue.toArray().slice(1);
let arr2: Array<userAndTime> = arr.map((u, index) => {
return {
user: u,
time: this.turnTime * 1000 + (index - 1) * (kTurnTimeSeconds * 1000)
};
}, this);
this.emit('turnQueue', arr2);
}
}
// A slot.
class VirtualMachine extends EventEmitter {
private vm: QemuVM;
private display: QemuDisplay;
private timer: ExtendableTimer = null;
private users: Array<VMUser> = [];
private queue: TurnQueue = new TurnQueue();
constructor(vm: QemuVM) {
super();
this.vm = vm;
this.timer = new ExtendableTimer(2);
this.timer.on('expired', async () => {
// bye bye!
console.log(`[VM] VM expired, resetting..`);
await this.vm.Stop();
});
this.timer.on('expiry-near', async () => {
console.log(`[VM] about to expire!`);
});
this.vm.on('statechange', async (state: VMState) => {
if (state == VMState.Started) {
this.display = this.vm.GetDisplay();
await this.VMRunning();
} else if (state == VMState.Stopped) {
this.display = null;
await this.VMStopped();
}
});
this.queue.on('turnQueue', (arr: Array<userAndTime>) => {
// TODO! SERIALIZE TURN QUEUE!
console.log("Turn queue", arr);
for (let entry of arr) {
entry.user.SendMessage((encoder: Shared.MessageEncoder) => {
// painnnnnnnnnnnnnnnnnnn fuck i should just make a dynamic buffer system lol
encoder.Init(4 + arr.length * Shared.kMaxUserNameLength);
// pain ?
encoder.SetTurnSrvMessage(
entry.time,
arr.map((entry: userAndTime) => {
return entry.user.username;
})
);
return encoder.Finish();
});
}
});
}
async Start() {
await this.vm.Start();
}
async AddUser(user: VMUser) {
user.username = VMUser.GenerateName();
console.log(user.username, 'joined.');
// send bullshit
await this.sendFullScreen(user);
// send an adduser for all users
for (let user of this.users) {
user.SendMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4 + Shared.kMaxUserNameLength);
encoder.SetAddUserMessage(user.username);
return encoder.Finish();
});
}
// officially add the user
this.users.push(user);
// hello!
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4 + Shared.kMaxUserNameLength);
encoder.SetAddUserMessage(user.username);
return encoder.Finish();
});
}
async RemUser(user: VMUser) {
// TODO: erase from turn queue (once we have it) wired up
this.users.splice(this.users.indexOf(user), 1);
// bye-bye!
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4 + Shared.kMaxUserNameLength);
encoder.SetRemUserMessage(user.username);
return encoder.Finish();
});
}
async OnWSMessage(user: VMUser, message: Buffer) {
try {
this.OnDecodedMessage(user, await Shared.MessageDecoder.ReadMessage(message, false));
} catch (err) {
// get out
user.connection.close();
return;
}
}
private async OnDecodedMessage(user: VMUser, message: Shared.DeserializedMessage) {
switch (message.type) {
case Shared.MessageType.Chat:
console.log(`${user.username} > ${(message as Shared.ChatMessage).message}`);
break;
case Shared.MessageType.Turn:
this.queue.TryEnqueue(user);
break;
case Shared.MessageType.Mouse:
if(user != this.queue.CurrentUser())
return;
if(this.display == null) return;
this.display.MouseEvent((message as Shared.MouseMessage).x, (message as Shared.MouseMessage).y, (message as Shared.MouseMessage).buttons);
break;
case Shared.MessageType.Key:
if(user != this.queue.CurrentUser())
return;
if(this.display == null) return;
break;
// ignore unhandlable messages (we won't get any invalid ones because they will cause a throw)
default:
break;
}
}
private async BroadcastMessage(messageGenerator: (encoder: Shared.MessageEncoder) => ArrayBuffer) {
let buffer = messageGenerator(new Shared.MessageEncoder());
for (let user of this.users) {
await user.SendBuffer(buffer);
}
}
private async InsertCD(isoPath: string) {
await this.vm.ChangeRemovableMedia('vm.cd', isoPath);
}
private async VMRunning() {
let self = this;
// Hook up the display
this.display.on('resize', async (width, height) => {
if(self.display == null)
return;
await self.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4);
encoder.SetDisplaySizeMessage(width, height);
return encoder.Finish();
});
// sexy cream!
let canvas = self.display.GetCanvas();
if(canvas == null)
return;
let buffer = canvas.toBuffer('image/jpeg', { quality: 0.75 });
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(buffer.length + 256);
encoder.SetDisplayRectMessage(0, 0, buffer);
return encoder.Finish();
});
});
this.display.on('rect', async (x, y, rect: ImageData) => {
let canvas = new Canvas(rect.width, rect.height);
canvas.getContext('2d').putImageData(rect, 0, 0);
let buffer = canvas.toBuffer('image/jpeg', { quality: 0.75 });
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(buffer.length + 256);
encoder.SetDisplayRectMessage(x, y, buffer);
return encoder.Finish();
});
});
this.timer.Start();
}
private async sendFullScreen(user: VMUser) {
if (this.display == null) return;
let buffer = this.display.GetCanvas().toBuffer('image/jpeg', { quality: 0.75 });
await user.SendMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(8);
encoder.SetDisplaySizeMessage(this.display.Size().width, this.display.Size().height);
return encoder.Finish();
});
await user.SendMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(buffer.length + 256);
encoder.SetDisplayRectMessage(0, 0, buffer);
return encoder.Finish();
});
}
private async VMStopped() {
await this.vm.Start();
}
}
export class SocketComputerServer {
private vm: VirtualMachine = null;
private fastify: FastifyInstance = fastify({
exposeHeadRoutes: false
});
Init() {
this.fastify.register(fastifyWebsocket.default);
this.fastify.register(async (app, _) => this.CTRoutes(app), {});
}
async Listen() {
try {
console.log('Backend starting...');
// create teh VM!!!!
await this.CreateVM();
await this.fastify.listen({
host: '127.0.0.1',
port: 4050
});
} catch (err) {
return;
}
}
async CreateVM() {
let diskpath = '/srv/collabvm/vms/socket1/socket1.qcow2';
let slotDef: QemuVmDefinition = Slot_PCDef('2G', '-netdev user,id=vm.wan', 'rtl8139', await GenMacAddress(), true, diskpath, 'qcow2');
setSnapshot(true);
// create the slot for real!
this.vm = new VirtualMachine(new QemuVM(slotDef));
await this.vm.Start(); // boot it up
}
CTRoutes(app: FastifyInstance) {
let self = this;
app.get('/', { websocket: true }, (connection: fastifyWebsocket.WebSocket) => {
new VMUser(connection, self.vm);
});
}
}

8
backend/src/index.ts Normal file
View file

@ -0,0 +1,8 @@
import { SocketComputerServer } from './SocketComputerServer.js';
(async () => {
let backend = new SocketComputerServer();
backend.Init();
await backend.Listen();
})();

11
backend/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "."
},
"references": [
{ "path": "../shared" }
]
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "socketcomputer-repo",
"private": "true",
"workspaces": [
"shared",
"backend",
"qemu",
"webapp"
],
"scripts": {
"build:frontend": "npm -w shared run build && npm -w webapp run build",
"build:service": "npm -w shared run build && npm -w qemu run build && npm -w backend run build"
},
"dependencies": {
"canvas": "^2.11.2"
},
"devDependencies": {
"@types/node": "^20.12.2",
"prettier": "^3.2.5",
"typescript": "^5.4.3"
}
}

19
qemu/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "@socketcomputer/qemu",
"version": "1.0.0",
"private": "true",
"description": "QEMU runtime for socketcomputer backend",
"main": "dist/src/index.js",
"type": "module",
"scripts": {
"build": "tsc"
},
"author": "",
"license": "MIT",
"dependencies": {
"canvas": "^2.11.2",
"execa": "^8.0.1",
"split": "^1.0.1"
}
}

143
qemu/src/QemuDisplay.ts Normal file
View file

@ -0,0 +1,143 @@
import { VncClient } from './rfb/client.js';
import { EventEmitter } from 'node:events';
import { Canvas, CanvasRenderingContext2D, createImageData } from 'canvas';
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: VncRect[] = [];
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));
}
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() {
return {
width: this.displayVnc.clientWidth,
height: this.displayVnc.clientHeight
};
}
MouseEvent(x, y, buttons) {
this.displayVnc.sendPointerEvent(x, y, buttons);
}
KeyboardEvent(keysym, pressed) {
this.displayVnc.sendKeyEvent(keysym, pressed);
}
}

33
qemu/src/QemuUtil.ts Normal file
View file

@ -0,0 +1,33 @@
// QEMU utility functions
// most of these are just for randomly generated/temporary files
import { execa } from 'execa';
import * as crypto from 'node:crypto';
/// Temporary path base for hard drive images.
const kVmHdaTmpPathBase = `/mnt/vmi/tmp/crusttest-hda`;
// 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(':')
);
});
});
}

286
qemu/src/QemuVM.ts Normal file
View file

@ -0,0 +1,286 @@
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) {
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();
this.SetState(VMState.Stopped);
}
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) {
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) {
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();
}
}
});
}
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) {
}
}
}

135
qemu/src/QmpClient.ts Normal file
View file

@ -0,0 +1,135 @@
// 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, port) {
super.connect(port, host);
this.ConnectImpl();
}
ConnectUNIX(path: string) {
super.connect(path);
this.ConnectImpl();
}
}

3
qemu/src/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from './QemuDisplay.js';
export * from './QemuUtil.js';
export * from './QemuVM.js';

21
qemu/src/rfb/LICENSE Normal file
View file

@ -0,0 +1,21 @@
Copyright 2021 Filipe Calaça Barbosa
Copyright 2022 dither
Copyright 2023 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.

10
qemu/src/rfb/README.md Normal file
View file

@ -0,0 +1,10 @@
# 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 `@socketcomputer/qemu` package:
- converted to TypeScript
- all modules rewritten to use ESM
- some noisy debug prints removed
- (some, very tiny) code cleanup

885
qemu/src/rfb/client.ts Normal file
View file

@ -0,0 +1,885 @@
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';
export class VncClient extends EventEmitter {
// These are in no particular order.
public debug: Boolean;
private _connected: Boolean;
private _authenticated: Boolean;
private _version: string;
private _password: string;
private _audioChannels: number;
private _audioFrequency: number;
private _rects: number;
private _decoders: any; // no real good way to type this yet. will do it later
private _fps: number;
private _timerInterval: number;
private _timerPointer;
public fb: Buffer;
private _handshaked: Boolean;
private _waitingServerInit: Boolean;
private _expectingChallenge: Boolean;
private _challengeResponseSent: Boolean;
private _set8BitColor: Boolean;
private _frameBufferReady = false;
private _firstFrameReceived = false;
private _processingFrame = false;
private _relativePointer: Boolean;
public bigEndianFlag: Boolean;
public clientWidth: number;
public clientHeight: number;
public clientName: string;
public pixelFormat: any;
private _colorMap: any[];
private _audioData: Buffer;
private _cursor: any;
public encodings: number[];
private _connection: net.Socket;
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 = {};
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) {
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 /* = {
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) {
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(() => {
this.requestFrameUpdate(true);
}, 1000);
}
/**
* Update the frame buffer size according to client width and height (RGBA)
*/
updateFbSize() {
this.fb = Buffer.alloc(this.clientWidth * this.clientHeight * 4);
}
/**
* Request the server to change to 8bit color format (Color palette). Only works with Raw encoding.
* @private
*/
_setPixelFormatToColorMap() {
this._log(`Requesting PixelFormat change to ColorMap (8 bits).`);
const message = Buffer.alloc(20);
message.writeUInt8(0); // Tipo da mensagem
message.writeUInt8(0, 1); // Padding
message.writeUInt8(0, 2); // Padding
message.writeUInt8(0, 3); // Padding
message.writeUInt8(8, 4); // PixelFormat - BitsPerPixel
message.writeUInt8(8, 5); // PixelFormat - Depth
message.writeUInt8(0, 6); // PixelFormat - BigEndianFlag
message.writeUInt8(0, 7); // PixelFormat - TrueColorFlag
message.writeUInt16BE(255, 8); // PixelFormat - RedMax
message.writeUInt16BE(255, 10); // PixelFormat - GreenMax
message.writeUInt16BE(255, 12); // PixelFormat - BlueMax
message.writeUInt8(0, 14); // PixelFormat - RedShift
message.writeUInt8(8, 15); // PixelFormat - GreenShift
message.writeUInt8(16, 16); // PixelFormat - BlueShift
message.writeUInt8(0, 17); // PixelFormat - Padding
message.writeUInt8(0, 18); // PixelFormat - Padding
message.writeUInt8(0, 19); // PixelFormat - Padding
// Envia um setPixelFormat trocando para mapa de cores
this._connection.write(message);
this.pixelFormat.bitsPerPixel = 8;
this.pixelFormat.depth = 8;
}
/**
* Send supported encodings
* @private
*/
_sendEncodings() {
//this._log('Sending encodings.');
// If this._set8BitColor is set, only copyrect and raw encodings are supported
const message = Buffer.alloc(4 + (!this._set8BitColor ? this.encodings.length : 2) * 4);
message.writeUInt8(2); // Message type
message.writeUInt8(0, 1); // Padding
message.writeUInt16BE(!this._set8BitColor ? this.encodings.length : 2, 2); // Padding
let offset = 4;
// If 8bits is not set, send all encodings configured
if (!this._set8BitColor) {
for (const e of this.encodings) {
message.writeInt32BE(e, offset);
offset += 4;
}
} else {
message.writeInt32BE(consts.encodings.copyRect, offset);
message.writeInt32BE(consts.encodings.raw, offset + 4);
}
this._connection.write(message);
}
/**
* Send client init msg
* @private
*/
_sendClientInit() {
//this._log(`Sending clientInit`);
this._waitingServerInit = true;
// Shared bit set
this._connection.write('1');
}
/**
* Handle data msg
* @returns {Promise<void>}
* @private
*/
async _handleData() {
if (!this._rects) {
switch (this._socketBuffer.buffer[0]) {
case consts.serverMsgTypes.fbUpdate:
await this._handleFbUpdate();
break;
case consts.serverMsgTypes.setColorMap:
await this._handleSetColorMap();
break;
case consts.serverMsgTypes.bell:
this.emit('bell');
this._socketBuffer.flush();
break;
case consts.serverMsgTypes.cutText:
await this._handleCutText();
break;
case consts.serverMsgTypes.qemuAudio:
await this._handleQemuAudio();
break;
}
}
}
/**
* Cut message (text was copied to clipboard on server)
* @returns {Promise<void>}
* @private
*/
async _handleCutText() {
this._socketBuffer.setOffset(4);
await this._socketBuffer.waitBytes(1);
const length = this._socketBuffer.readUInt32BE();
await this._socketBuffer.waitBytes(length);
this.emit('cutText', this._socketBuffer.readNBytesOffset(length).toString());
this._socketBuffer.flush();
}
/**
* Gets the pseudocursor framebuffer
*/
_getPseudoCursor() {
if (!this._cursor.width)
return {
width: 1,
height: 1,
data: Buffer.alloc(4)
};
const { width, height, bitmask, cursorPixels } = this._cursor;
const data = Buffer.alloc(height * width * 4);
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
const offset = (y * width + x) * 4;
const active = (bitmask[Math.floor((width + 7) / 8) * y + Math.floor(x / 8)] >> (7 - (x % 8))) & 1;
if (active) {
switch (this.pixelFormat.bitsPerPixel) {
case 8:
console.log(8);
const index = cursorPixels.readUInt8(offset);
const color = this._colorMap[index] | 0xff;
data.writeIntBE(color, offset, 4);
break;
case 32:
// TODO: compatibility with VMware actually using the alpha channel
const b = cursorPixels.readUInt8(offset);
const g = cursorPixels.readUInt8(offset + 1);
const r = cursorPixels.readUInt8(offset + 2);
data.writeUInt8(r, offset);
data.writeUInt8(g, offset + 1);
data.writeUInt8(b, offset + 2);
data.writeUInt8(0xff, offset + 3);
break;
default:
data.writeIntBE(cursorPixels.readIntBE(offset, this.pixelFormat.bitsPerPixel / 8), offset, this.pixelFormat.bitsPerPixel / 8);
break;
}
}
}
}
return {
x: this._cursor.x,
y: this._cursor.y,
width,
height,
data
};
}
/**
* Handle a rects of update message
*/
async _handleRect() {
this._processingFrame = true;
const sendFbUpdate = this._rects;
while (this._rects) {
await this._socketBuffer.waitBytes(12);
const rect: any = {};
rect.x = this._socketBuffer.readUInt16BE();
rect.y = this._socketBuffer.readUInt16BE();
rect.width = this._socketBuffer.readUInt16BE();
rect.height = this._socketBuffer.readUInt16BE();
rect.encoding = this._socketBuffer.readInt32BE();
if (rect.encoding === consts.encodings.pseudoQemuAudio) {
this.sendAudio(true);
this.sendAudioConfig(this._audioChannels, this._audioFrequency); //todo: future: setFrequency(...) to update mid thing
} else if (rect.encoding === consts.encodings.pseudoQemuPointerMotionChange) {
this._relativePointer = rect.x == 0;
} else if (rect.encoding === consts.encodings.pseudoCursor) {
const dataSize = rect.width * rect.height * (this.pixelFormat.bitsPerPixel / 8);
const bitmaskSize = Math.floor((rect.width + 7) / 8) * rect.height;
this._cursor.width = rect.width;
this._cursor.height = rect.height;
this._cursor.x = rect.x;
this._cursor.y = rect.y;
this._cursor.cursorPixels = this._socketBuffer.readNBytesOffset(dataSize);
this._cursor.bitmask = this._socketBuffer.readNBytesOffset(bitmaskSize);
rect.data = Buffer.concat([this._cursor.cursorPixels, this._cursor.bitmask]);
this.emit('cursorChanged', this._getPseudoCursor());
} else if (rect.encoding === consts.encodings.pseudoDesktopSize) {
this._log('Frame Buffer size change requested by the server', true);
this.clientHeight = rect.height;
this.clientWidth = rect.width;
this.updateFbSize();
this.emit('desktopSizeChanged', { width: this.clientWidth, height: this.clientHeight });
} else if (this._decoders[rect.encoding]) {
await this._decoders[rect.encoding].decode(
rect,
this.fb,
this.pixelFormat.bitsPerPixel,
this._colorMap,
this.clientWidth,
this.clientHeight,
this._socketBuffer,
this.pixelFormat.depth
);
this.emit('rectUpdateProcessed', rect);
} else {
this._log('Non supported update received. Encoding: ' + rect.encoding);
}
this._rects--;
this.emit('rectProcessed', rect);
if (!this._rects) {
this._socketBuffer.flush(true);
}
}
if (sendFbUpdate) {
if (!this._firstFrameReceived) {
this._firstFrameReceived = true;
this.emit('firstFrameUpdate', this.fb);
}
this._log('Frame buffer updated.', true);
this.emit('frameUpdated', this.fb);
}
this._processingFrame = false;
if (this._fps === 0) {
// If FPS is not set, request a new update as soon as the last received has been processed
this.requestFrameUpdate();
}
}
async _handleFbUpdate() {
this._socketBuffer.setOffset(2);
this._rects = this._socketBuffer.readUInt16BE();
this._log('Frame update received. Rects: ' + this._rects, true);
await this._handleRect();
}
/**
* Handle setColorMap msg
* @returns {Promise<void>}
* @private
*/
async _handleSetColorMap() {
this._socketBuffer.setOffset(2);
let firstColor = this._socketBuffer.readUInt16BE();
const numColors = this._socketBuffer.readUInt16BE();
this._log(`ColorMap received. Colors: ${numColors}.`);
await this._socketBuffer.waitBytes(numColors * 6);
for (let x = 0; x < numColors; x++) {
this._colorMap[firstColor] = {
r: Math.floor((this._socketBuffer.readUInt16BE() / 65535) * 255),
g: Math.floor((this._socketBuffer.readUInt16BE() / 65535) * 255),
b: Math.floor((this._socketBuffer.readUInt16BE() / 65535) * 255)
};
firstColor++;
}
this.emit('colorMapUpdated', this._colorMap);
this._socketBuffer.flush();
}
async _handleQemuAudio() {
this._socketBuffer.setOffset(2);
let operation = this._socketBuffer.readUInt16BE();
if (operation == 2) {
const length = this._socketBuffer.readUInt32BE();
//this._log(`Audio received. Length: ${length}.`);
await this._socketBuffer.waitBytes(length);
let audioBuffer = this._socketBuffer.readNBytes(length);
this._audioData = audioBuffer;
}
this.emit('audioStream', this._audioData);
this._socketBuffer.flush();
}
/**
* Reset the class state
*/
resetState() {
if (this._connection) {
this._connection.end();
}
if (this._timerPointer) {
clearInterval(this._timerPointer);
}
this._timerPointer = null;
//this._connection = null;
this._connected = false;
this._authenticated = false;
this._version = '';
this._password = '';
this._audioChannels = 2;
this._audioFrequency = 22050;
this._handshaked = false;
this._expectingChallenge = false;
this._challengeResponseSent = false;
this._frameBufferReady = false;
this._firstFrameReceived = false;
this._processingFrame = false;
this.clientWidth = 0;
this.clientHeight = 0;
this.clientName = '';
this.pixelFormat = {
bitsPerPixel: 0,
depth: 0,
bigEndianFlag: 0,
trueColorFlag: 0,
redMax: 0,
greenMax: 0,
blueMax: 0,
redShift: 0,
blueShift: 0,
greenShift: 0
};
this._rects = 0;
this._colorMap = [];
this.fb = null;
this._socketBuffer?.flush(false);
this._cursor = {
width: 0,
height: 0,
x: 0,
y: 0,
cursorPixels: null,
bitmask: null
};
}
// NIT(lily) instead of allocating it'd be way better if these just re-used a 16/32 byte buffer used just for these.
/**
* Send a key event
* @param key - Key code (keysym) defined by X Window System, check https://wiki.linuxquestions.org/wiki/List_of_keysyms
* @param down - True if the key is pressed, false if it is not
*/
sendKeyEvent(key, down = false) {
const message = Buffer.alloc(8);
message.writeUInt8(4); // Message type
message.writeUInt8(down ? 1 : 0, 1); // Down flag
message.writeUInt8(0, 2); // Padding
message.writeUInt8(0, 3); // Padding
message.writeUInt32BE(key, 4); // Key code
this._connection.write(message);
}
/**
* Send a raw pointer event
* @param xPosition - X Position
* @param yPosition - Y Position
* @param mask - Raw RFB button mask
*/
sendPointerEvent(xPosition, yPosition, buttonMask) {
const message = Buffer.alloc(6);
message.writeUInt8(consts.clientMsgTypes.pointerEvent); // Message type
message.writeUInt8(buttonMask, 1); // Button Mask
const reladd = this._relativePointer ? 0x7fff : 0;
message.writeUInt16BE(xPosition + reladd, 2); // X Position
message.writeUInt16BE(yPosition + reladd, 4); // Y Position
this._cursor.posX = xPosition;
this._cursor.posY = yPosition;
this._connection.write(message);
}
/**
* Send client cut message to server
* @param text - latin1 encoded
*/
clientCutText(text) {
const textBuffer = Buffer.from(text, 'latin1');
const message = Buffer.alloc(8 + textBuffer.length);
message.writeUInt8(6); // Message type
message.writeUInt8(0, 1); // Padding
message.writeUInt8(0, 2); // Padding
message.writeUInt8(0, 3); // Padding
message.writeUInt32BE(textBuffer.length, 4); // Padding
textBuffer.copy(message, 8);
this._connection.write(message);
}
sendAudio(enable) {
const message = Buffer.alloc(4);
message.writeUInt8(consts.clientMsgTypes.qemuAudio); // Message type
message.writeUInt8(1, 1); // Submessage Type
message.writeUInt16BE(enable ? 0 : 1, 2); // Operation
this._connection.write(message);
}
sendAudioConfig(channels, frequency) {
const message = Buffer.alloc(10);
message.writeUInt8(consts.clientMsgTypes.qemuAudio); // Message type
message.writeUInt8(1, 1); // Submessage Type
message.writeUInt16BE(2, 2); // Operation
message.writeUInt8(0 /*U8*/, 4); // Sample Format
message.writeUInt8(channels, 5); // Number of Channels
message.writeUInt32BE(frequency, 6); // Frequency
this._connection.write(message);
}
/**
* Print log info
* @param text
* @param debug
* @private
*/
_log(text, debug = false) {
if (!debug || (debug && this.debug)) {
console.log(text);
}
}
}

46
qemu/src/rfb/constants.ts Normal file
View file

@ -0,0 +1,46 @@
// based on https://github.com/sidorares/node-rfb2
export const consts = {
clientMsgTypes: {
setPixelFormat: 0,
setEncodings: 2,
fbUpdate: 3,
keyEvent: 4,
pointerEvent: 5,
cutText: 6,
qemuAudio: 255
},
serverMsgTypes: {
fbUpdate: 0,
setColorMap: 1,
bell: 2,
cutText: 3,
qemuAudio: 255
},
versionString: {
V3_003: 'RFB 003.003\n',
V3_007: 'RFB 003.007\n',
V3_008: 'RFB 003.008\n'
},
encodings: {
raw: 0,
copyRect: 1,
rre: 2,
corre: 4,
hextile: 5,
zlib: 6,
tight: 7,
zlibhex: 8,
trle: 15,
zrle: 16,
h264: 50,
pseudoCursor: -239,
pseudoDesktopSize: -223,
pseudoQemuPointerMotionChange: -257,
pseudoQemuAudio: -259
},
security: {
None: 1,
VNC: 2
}
};

View file

@ -0,0 +1,29 @@
export class CopyRectDecoder {
getPixelBytePos(x, y, width, height) {
return (y * width + x) * 4;
}
async decode(rect, fb, bitsPerPixel, colorMap, screenW, screenH, socket, depth): Promise<void> {
return new Promise(async (resolve, reject) => {
await socket.waitBytes(4);
rect.data = socket.readNBytesOffset(4);
const x = rect.data.readUInt16BE();
const y = rect.data.readUInt16BE(2);
for (let h = 0; h < rect.height; h++) {
for (let w = 0; w < rect.width; w++) {
const fbOrigBytePosOffset = this.getPixelBytePos(x + w, y + h, screenW, screenH);
const fbBytePosOffset = this.getPixelBytePos(rect.x + w, rect.y + h, screenW, screenH);
fb.writeUInt8(fb.readUInt8(fbOrigBytePosOffset), fbBytePosOffset);
fb.writeUInt8(fb.readUInt8(fbOrigBytePosOffset + 1), fbBytePosOffset + 1);
fb.writeUInt8(fb.readUInt8(fbOrigBytePosOffset + 2), fbBytePosOffset + 2);
fb.writeUInt8(fb.readUInt8(fbOrigBytePosOffset + 3), fbBytePosOffset + 3);
}
}
resolve();
});
}
}

View file

@ -0,0 +1,241 @@
export class HextileDecoder {
getPixelBytePos(x, y, width, height) {
return (y * width + x) * 4;
}
async decode(rect, fb, bitsPerPixel, colorMap, screenW, screenH, socket, depth): Promise<void> {
return new Promise(async (resolve, reject) => {
const initialOffset = socket.offset;
let dataSize = 0;
let tiles;
let totalTiles;
let tilesX;
let tilesY;
let lastSubEncoding;
const backgroundColor = { r: 0, g: 0, b: 0, a: 255 };
const foregroundColor = { r: 0, g: 0, b: 0, a: 255 };
tilesX = Math.ceil(rect.width / 16);
tilesY = Math.ceil(rect.height / 16);
tiles = tilesX * tilesY;
totalTiles = tiles;
while (tiles) {
await socket.waitBytes(1);
const subEncoding = socket.readUInt8();
dataSize++;
const currTile = totalTiles - tiles;
// Calculate tile position and size
const tileX = currTile % tilesX;
const tileY = Math.floor(currTile / tilesX);
const tx = rect.x + tileX * 16;
const ty = rect.y + tileY * 16;
const tw = Math.min(16, rect.x + rect.width - tx);
const th = Math.min(16, rect.y + rect.height - ty);
if (subEncoding === 0) {
if (lastSubEncoding & 0x01) {
// We need to ignore zeroed tile after a raw tile
} else {
// If zeroed tile and last tile was not raw, use the last backgroundColor
this.applyColor(tw, th, tx, ty, screenW, screenH, backgroundColor, fb);
}
} else if (subEncoding & 0x01) {
// If Raw, ignore all other bits
await socket.waitBytes(th * tw * (bitsPerPixel / 8));
dataSize += th * tw * (bitsPerPixel / 8);
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
if (bitsPerPixel === 8) {
const index = socket.readUInt8();
const color = colorMap[index];
// RGB
// fb.writeUInt8(color?.r || 255, fbBytePosOffset);
// fb.writeUInt8(color?.g || 255, fbBytePosOffset + 1);
// fb.writeUInt8(color?.b || 255, fbBytePosOffset + 2);
// BGR
fb.writeUInt8(color?.r || 255, fbBytePosOffset + 2);
fb.writeUInt8(color?.g || 255, fbBytePosOffset + 1);
fb.writeUInt8(color?.b || 255, fbBytePosOffset);
} else if (bitsPerPixel === 24) {
fb.writeUInt8(socket.readUInt8(), fbBytePosOffset);
fb.writeUInt8(socket.readUInt8(), fbBytePosOffset + 1);
fb.writeUInt8(socket.readUInt8(), fbBytePosOffset + 2);
} else if (bitsPerPixel === 32) {
// RGB
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset), fbBytePosOffset);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 1), fbBytePosOffset + 1);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 2), fbBytePosOffset + 2);
// BGR
fb.writeUInt8(socket.readUInt8(), fbBytePosOffset + 2);
fb.writeUInt8(socket.readUInt8(), fbBytePosOffset + 1);
fb.writeUInt8(socket.readUInt8(), fbBytePosOffset);
socket.readUInt8();
}
// Alpha, always 255
fb.writeUInt8(255, fbBytePosOffset + 3);
}
}
lastSubEncoding = subEncoding;
} else {
// Background bit
if (subEncoding & 0x02) {
switch (bitsPerPixel) {
case 8:
await socket.waitBytes(1);
const index = socket.readUInt8();
dataSize++;
backgroundColor.r = colorMap[index].r || 255;
backgroundColor.g = colorMap[index].g || 255;
backgroundColor.b = colorMap[index].b || 255;
break;
case 24:
await socket.waitBytes(3);
dataSize += 3;
backgroundColor.r = socket.readUInt8();
backgroundColor.g = socket.readUInt8();
backgroundColor.b = socket.readUInt8();
break;
case 32:
await socket.waitBytes(4);
dataSize += 4;
backgroundColor.r = socket.readUInt8();
backgroundColor.g = socket.readUInt8();
backgroundColor.b = socket.readUInt8();
backgroundColor.a = socket.readUInt8();
break;
}
}
// Foreground bit
if (subEncoding & 0x04) {
switch (bitsPerPixel) {
case 8:
await socket.waitBytes(1);
const index = socket.readUInt8();
dataSize++;
foregroundColor.r = colorMap[index].r || 255;
foregroundColor.g = colorMap[index].g || 255;
foregroundColor.b = colorMap[index].b || 255;
break;
case 24:
await socket.waitBytes(3);
dataSize += 3;
foregroundColor.r = socket.readUInt8();
foregroundColor.g = socket.readUInt8();
foregroundColor.b = socket.readUInt8();
break;
case 32:
await socket.waitBytes(4);
dataSize += 4;
foregroundColor.r = socket.readUInt8();
foregroundColor.g = socket.readUInt8();
foregroundColor.b = socket.readUInt8();
foregroundColor.a = socket.readUInt8();
break;
}
}
// Initialize tile with the background color
this.applyColor(tw, th, tx, ty, screenW, screenH, backgroundColor, fb);
// AnySubrects bit
if (subEncoding & 0x08) {
await socket.waitBytes(1);
let subRects = socket.readUInt8();
if (subRects) {
while (subRects) {
subRects--;
const color = { r: 0, g: 0, b: 0, a: 255 };
// SubrectsColoured
if (subEncoding & 0x10) {
switch (bitsPerPixel) {
case 8:
await socket.waitBytes(1);
const index = socket.readUInt8();
dataSize++;
color.r = colorMap[index].r || 255;
color.g = colorMap[index].g || 255;
color.b = colorMap[index].b || 255;
break;
case 24:
await socket.waitBytes(3);
dataSize += 3;
color.r = socket.readUInt8();
color.g = socket.readUInt8();
color.b = socket.readUInt8();
break;
case 32:
await socket.waitBytes(4);
dataSize += 4;
color.r = socket.readUInt8();
color.g = socket.readUInt8();
color.b = socket.readUInt8();
color.a = socket.readUInt8();
break;
}
} else {
color.r = foregroundColor.r;
color.g = foregroundColor.g;
color.b = foregroundColor.b;
color.a = foregroundColor.a;
}
await socket.waitBytes(2);
const xy = socket.readUInt8();
const wh = socket.readUInt8();
dataSize += 2;
const sx = xy >> 4;
const sy = xy & 0x0f;
const sw = (wh >> 4) + 1;
const sh = (wh & 0x0f) + 1;
this.applyColor(sw, sh, tx + sx, ty + sy, screenW, screenH, color, fb);
}
} else {
this.applyColor(tw, th, tx, ty, screenW, screenH, backgroundColor, fb);
}
} else {
this.applyColor(tw, th, tx, ty, screenW, screenH, backgroundColor, fb);
}
lastSubEncoding = subEncoding;
}
tiles--;
}
rect.data = socket.readNBytes(dataSize, initialOffset);
resolve();
});
}
// Apply color to a rect on buffer
applyColor(tw, th, tx, ty, screenW, screenH, color, fb) {
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
fb.writeUInt8(color.r || 255, fbBytePosOffset + 2);
fb.writeUInt8(color.g || 255, fbBytePosOffset + 1);
fb.writeUInt8(color.b || 255, fbBytePosOffset);
fb.writeUInt8(255, fbBytePosOffset + 3);
}
}
}
}

View file

@ -0,0 +1,54 @@
export class RawDecoder {
getPixelBytePos(x, y, width, height) {
return (y * width + x) * 4;
}
async decode(rect, fb, bitsPerPixel, colorMap, screenW, screenH, socket, depth): Promise<void> {
return new Promise(async (resolve, reject) => {
await socket.waitBytes(rect.width * rect.height * (bitsPerPixel / 8));
rect.data = socket.readNBytesOffset(rect.width * rect.height * (bitsPerPixel / 8));
for (let h = 0; h < rect.height; h++) {
for (let w = 0; w < rect.width; w++) {
const fbBytePosOffset = this.getPixelBytePos(rect.x + w, rect.y + h, screenW, screenH);
if (bitsPerPixel === 8) {
const bytePosOffset = h * rect.width + w;
const index = rect.data.readUInt8(bytePosOffset);
const color = colorMap[index];
// RGB
// fb.writeUInt8(color?.r || 255, fbBytePosOffset);
// fb.writeUInt8(color?.g || 255, fbBytePosOffset + 1);
// fb.writeUInt8(color?.b || 255, fbBytePosOffset + 2);
// BGR
fb.writeUInt8(color?.r || 255, fbBytePosOffset + 2);
fb.writeUInt8(color?.g || 255, fbBytePosOffset + 1);
fb.writeUInt8(color?.b || 255, fbBytePosOffset);
fb.writeUInt8(255, fbBytePosOffset + 3);
} else if (bitsPerPixel === 24) {
const bytePosOffset = (h * rect.width + w) * 3;
fb.writeUInt8(rect.data.readUInt8(bytePosOffset), fbBytePosOffset);
fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 1), fbBytePosOffset + 1);
fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 2), fbBytePosOffset + 2);
fb.writeUInt8(255, fbBytePosOffset + 3);
} else if (bitsPerPixel === 32) {
const bytePosOffset = (h * rect.width + w) * 4;
// RGB
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset), fbBytePosOffset);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 1), fbBytePosOffset + 1);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 2), fbBytePosOffset + 2);
// BGR
fb.writeUInt8(rect.data.readUInt8(bytePosOffset), fbBytePosOffset + 2);
fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 1), fbBytePosOffset + 1);
fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 2), fbBytePosOffset);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 3), fbBytePosOffset + 3);
fb.writeUInt8(255, fbBytePosOffset + 3);
}
}
}
resolve();
});
}
}

View file

@ -0,0 +1,357 @@
import * as zlib from 'node:zlib';
import { SocketBuffer } from '../socketbuffer.js';
export class ZrleDecoder {
private zlib: zlib.Inflate;
private unBuffer: SocketBuffer;
constructor() {
this.zlib = zlib.createInflate({ chunkSize: 16 * 1024 * 1024, flush: zlib.constants.Z_FULL_FLUSH });
this.unBuffer = new SocketBuffer();
this.zlib.on('data', async (chunk) => {
this.unBuffer.pushData(chunk);
});
}
getPixelBytePos(x, y, width, height) {
return (y * width + x) * 4;
}
async decode(rect, fb, bitsPerPixel, colorMap, screenW, screenH, socket, depth): Promise<void> {
return new Promise(async (resolve, reject) => {
await socket.waitBytes(4);
const initialOffset = socket.offset;
const dataSize = socket.readUInt32BE();
await socket.waitBytes(dataSize);
const compressedData = socket.readNBytesOffset(dataSize);
rect.data = socket.readNBytes(dataSize + 4, initialOffset);
this.unBuffer.flush();
this.zlib.write(compressedData, async () => {
this.zlib.flush();
let tiles;
let totalTiles;
let tilesX;
let tilesY;
tilesX = Math.ceil(rect.width / 64);
tilesY = Math.ceil(rect.height / 64);
tiles = tilesX * tilesY;
totalTiles = tiles;
while (tiles) {
await this.unBuffer.waitBytes(1, 'tile begin.');
const subEncoding = this.unBuffer.readUInt8();
const currTile = totalTiles - tiles;
const tileX = currTile % tilesX;
const tileY = Math.floor(currTile / tilesX);
const tx = rect.x + tileX * 64;
const ty = rect.y + tileY * 64;
const tw = Math.min(64, rect.x + rect.width - tx);
const th = Math.min(64, rect.y + rect.height - ty);
const now = process.hrtime.bigint();
let totalRun = 0;
let runs = 0;
if (subEncoding === 127 || subEncoding === 129) {
console.log('Invalid subencoding. ' + subEncoding);
} else if (subEncoding === 0) {
// Raw
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
if (bitsPerPixel === 8) {
await this.unBuffer.waitBytes(1, 'raw 8bits');
const index = this.unBuffer.readUInt8();
const color = colorMap[index];
// RGB
// fb.writeUInt8(color?.r || 255, fbBytePosOffset);
// fb.writeUInt8(color?.g || 255, fbBytePosOffset + 1);
// fb.writeUInt8(color?.b || 255, fbBytePosOffset + 2);
// BGR
fb.writeUInt8(color?.r || 255, fbBytePosOffset + 2);
fb.writeUInt8(color?.g || 255, fbBytePosOffset + 1);
fb.writeUInt8(color?.b || 255, fbBytePosOffset);
} else if (bitsPerPixel === 24 || (bitsPerPixel === 32 && depth === 24)) {
await this.unBuffer.waitBytes(3, 'raw 24bits');
fb.writeUInt8(this.unBuffer.readUInt8(), fbBytePosOffset + 2);
fb.writeUInt8(this.unBuffer.readUInt8(), fbBytePosOffset + 1);
fb.writeUInt8(this.unBuffer.readUInt8(), fbBytePosOffset);
} else if (bitsPerPixel === 32) {
// RGB
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset), fbBytePosOffset);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 1), fbBytePosOffset + 1);
// fb.writeUInt8(rect.data.readUInt8(bytePosOffset + 2), fbBytePosOffset + 2);
// BGR
await this.unBuffer.waitBytes(4, 'raw 32bits');
fb.writeUInt8(this.unBuffer.readUInt8(), fbBytePosOffset + 2);
fb.writeUInt8(this.unBuffer.readUInt8(), fbBytePosOffset + 1);
fb.writeUInt8(this.unBuffer.readUInt8(), fbBytePosOffset);
this.unBuffer.readUInt8();
}
// Alpha
fb.writeUInt8(255, fbBytePosOffset + 3);
}
}
} else if (subEncoding === 1) {
// Single Color
let color = { r: 0, g: 0, b: 0, a: 255 };
if (bitsPerPixel === 8) {
await this.unBuffer.waitBytes(1, 'single color 8bits');
const index = this.unBuffer.readUInt8();
color = colorMap[index];
} else if (bitsPerPixel === 24 || (bitsPerPixel === 32 && depth === 24)) {
await this.unBuffer.waitBytes(3, 'single color 24bits');
color.r = this.unBuffer.readUInt8();
color.g = this.unBuffer.readUInt8();
color.b = this.unBuffer.readUInt8();
} else if (bitsPerPixel === 32) {
await this.unBuffer.waitBytes(4, 'single color 32bits');
color.r = this.unBuffer.readUInt8();
color.g = this.unBuffer.readUInt8();
color.b = this.unBuffer.readUInt8();
color.a = this.unBuffer.readUInt8();
}
this.applyColor(tw, th, tx, ty, screenW, screenH, color, fb);
} else if (subEncoding >= 2 && subEncoding <= 16) {
// Palette
const palette = [];
for (let x = 0; x < subEncoding; x++) {
let color;
if (bitsPerPixel === 24 || (bitsPerPixel === 32 && depth === 24)) {
await this.unBuffer.waitBytes(3, 'palette 24 bits');
color = {
r: this.unBuffer.readUInt8(),
g: this.unBuffer.readUInt8(),
b: this.unBuffer.readUInt8(),
a: 255
};
} else if (bitsPerPixel === 32) {
await this.unBuffer.waitBytes(3, 'palette 32 bits');
color = {
r: this.unBuffer.readUInt8(),
g: this.unBuffer.readUInt8(),
b: this.unBuffer.readUInt8(),
a: this.unBuffer.readUInt8()
};
}
palette.push(color);
}
const bitsPerIndex = subEncoding === 2 ? 1 : subEncoding < 5 ? 2 : 4;
// const i = (tw * th) / (8 / bitsPerIndex);
// const pixels = [];
let byte;
let bitPos = 0;
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
if (bitPos === 0 || w === 0) {
await this.unBuffer.waitBytes(1, 'palette index data');
byte = this.unBuffer.readUInt8();
bitPos = 0;
}
let color;
switch (bitsPerIndex) {
case 1:
if (bitPos === 0) {
color = palette[(byte & 128) >> 7] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 1) {
color = palette[(byte & 64) >> 6] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 2) {
color = palette[(byte & 32) >> 5] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 3) {
color = palette[(byte & 16) >> 4] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 4) {
color = palette[(byte & 8) >> 3] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 5) {
color = palette[(byte & 4) >> 2] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 6) {
color = palette[(byte & 2) >> 1] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 7) {
color = palette[byte & 1] || { r: 255, g: 255, b: 255, a: 255 };
}
bitPos++;
if (bitPos === 8) {
bitPos = 0;
}
break;
case 2:
if (bitPos === 0) {
color = palette[(byte & 196) >> 6] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 1) {
color = palette[(byte & 48) >> 4] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 2) {
color = palette[(byte & 12) >> 2] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 3) {
color = palette[byte & 3] || { r: 255, g: 255, b: 255, a: 255 };
}
bitPos++;
if (bitPos === 4) {
bitPos = 0;
}
break;
case 4:
if (bitPos === 0) {
color = palette[(byte & 240) >> 4] || { r: 255, g: 255, b: 255, a: 255 };
} else if (bitPos === 1) {
color = palette[byte & 15] || { r: 255, g: 255, b: 255, a: 255 };
}
bitPos++;
if (bitPos === 2) {
bitPos = 0;
}
break;
}
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
fb.writeUInt8(color.b ?? 0, fbBytePosOffset);
fb.writeUInt8(color.g ?? 0, fbBytePosOffset + 1);
fb.writeUInt8(color.r ?? 0, fbBytePosOffset + 2);
fb.writeUInt8(color.a ?? 255, fbBytePosOffset + 3);
}
}
} else if (subEncoding === 128) {
// Plain RLE
let runLength = 0;
let color = { r: 0, g: 0, b: 0, a: 0 };
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
if (!runLength) {
if (bitsPerPixel === 24 || (bitsPerPixel === 32 && depth === 24)) {
await this.unBuffer.waitBytes(3, 'rle 24bits');
color = {
r: this.unBuffer.readUInt8(),
g: this.unBuffer.readUInt8(),
b: this.unBuffer.readUInt8(),
a: 255
};
} else if (bitsPerPixel === 32) {
await this.unBuffer.waitBytes(4, 'rle 32bits');
color = {
r: this.unBuffer.readUInt8(),
g: this.unBuffer.readUInt8(),
b: this.unBuffer.readUInt8(),
a: this.unBuffer.readUInt8()
};
}
await this.unBuffer.waitBytes(1, 'rle runsize');
let runSize = this.unBuffer.readUInt8();
while (runSize === 255) {
runLength += runSize;
await this.unBuffer.waitBytes(1, 'rle runsize');
runSize = this.unBuffer.readUInt8();
}
runLength += runSize + 1;
totalRun += runLength;
runs++;
}
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
fb.writeUInt8(color.b ?? 0, fbBytePosOffset);
fb.writeUInt8(color.g ?? 0, fbBytePosOffset + 1);
fb.writeUInt8(color.r ?? 0, fbBytePosOffset + 2);
fb.writeUInt8(color.a ?? 255, fbBytePosOffset + 3);
runLength--;
}
}
} else if (subEncoding >= 130) {
// Palette RLE
const paletteSize = subEncoding - 128;
const palette = [];
for (let x = 0; x < paletteSize; x++) {
let color;
if (bitsPerPixel === 24 || (bitsPerPixel === 32 && depth === 24)) {
await this.unBuffer.waitBytes(3, 'paletterle 24bits');
color = {
r: this.unBuffer.readUInt8(),
g: this.unBuffer.readUInt8(),
b: this.unBuffer.readUInt8(),
a: 255
};
} else if (bitsPerPixel === 32) {
await this.unBuffer.waitBytes(4, 'paletterle 32bits');
color = {
r: this.unBuffer.readUInt8(),
g: this.unBuffer.readUInt8(),
b: this.unBuffer.readUInt8(),
a: this.unBuffer.readUInt8()
};
}
palette.push(color);
}
let runLength = 0;
let color = { r: 0, g: 0, b: 0, a: 255 };
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
if (!runLength) {
await this.unBuffer.waitBytes(1, 'paletterle indexdata');
const colorIndex = this.unBuffer.readUInt8();
if (!(colorIndex & 128)) {
// Run de tamanho 1
color = palette[colorIndex] ?? { r: 0, g: 0, b: 0, a: 255 };
runLength = 1;
} else {
color = palette[colorIndex - 128] ?? { r: 0, g: 0, b: 0, a: 255 };
await this.unBuffer.waitBytes(1, 'paletterle runlength');
let runSize = this.unBuffer.readUInt8();
while (runSize === 255) {
runLength += runSize;
await this.unBuffer.waitBytes(1, 'paletterle runlength');
runSize = this.unBuffer.readUInt8();
}
runLength += runSize + 1;
}
totalRun += runLength;
runs++;
}
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
fb.writeUInt8(color.b ?? 0, fbBytePosOffset);
fb.writeUInt8(color.g ?? 0, fbBytePosOffset + 1);
fb.writeUInt8(color.r ?? 0, fbBytePosOffset + 2);
fb.writeUInt8(color.a ?? 255, fbBytePosOffset + 3);
runLength--;
}
}
}
// 127 and 129 are not valid
// 17 to 126 are not used
tiles--;
}
this.unBuffer.flush();
resolve();
});
});
}
// Apply color to a rect on buffer
applyColor(tw, th, tx, ty, screenW, screenH, color, fb) {
for (let h = 0; h < th; h++) {
for (let w = 0; w < tw; w++) {
const fbBytePosOffset = this.getPixelBytePos(tx + w, ty + h, screenW, screenH);
fb.writeUInt8(color.b || 255, fbBytePosOffset);
fb.writeUInt8(color.g || 255, fbBytePosOffset + 1);
fb.writeUInt8(color.r || 255, fbBytePosOffset + 2);
fb.writeUInt8(255, fbBytePosOffset + 3);
}
}
}
}

View file

@ -0,0 +1,132 @@
/// this is a pretty poor name.
export class SocketBuffer {
public buffer?: Buffer;
private offset: number;
constructor() {
this.flush();
}
flush(keep = true) {
if (keep && this.buffer?.length) {
this.buffer = this.buffer.subarray(this.offset);
this.offset = 0;
} else {
this.buffer = Buffer.from([]);
this.offset = 0;
}
}
toString() {
return this.buffer.toString();
}
includes(check) {
return this.buffer.includes(check);
}
pushData(data) {
this.buffer = Buffer.concat([this.buffer, data]);
}
readInt32BE() {
const data = this.buffer.readInt32BE(this.offset);
this.offset += 4;
return data;
}
readInt32LE() {
const data = this.buffer.readInt32LE(this.offset);
this.offset += 4;
return data;
}
readUInt32BE() {
const data = this.buffer.readUInt32BE(this.offset);
this.offset += 4;
return data;
}
readUInt32LE() {
const data = this.buffer.readUInt32LE(this.offset);
this.offset += 4;
return data;
}
readUInt16BE() {
const data = this.buffer.readUInt16BE(this.offset);
this.offset += 2;
return data;
}
readUInt16LE() {
const data = this.buffer.readUInt16LE(this.offset);
this.offset += 2;
return data;
}
readUInt8() {
const data = this.buffer.readUInt8(this.offset);
this.offset += 1;
return data;
}
readInt8() {
const data = this.buffer.readInt8(this.offset);
this.offset += 1;
return data;
}
readNBytes(bytes, offset = this.offset) {
return this.buffer.slice(offset, offset + bytes);
}
readNBytesOffset(bytes) {
const data = this.buffer.slice(this.offset, this.offset + bytes);
this.offset += bytes;
return data;
}
setOffset(n) {
this.offset = n;
}
bytesLeft() {
return this.buffer.length - this.offset;
}
// name is nullable because there are Many(yay....) times it just isn't passed
waitBytes(bytes, name: any | null = null): Promise<void> {
if (this.bytesLeft() >= bytes) {
return;
}
let counter = 0;
return new Promise(async (resolve, reject) => {
while (this.bytesLeft() < bytes) {
counter++;
// console.log('Esperando. BytesLeft: ' + this.bytesLeft() + ' Desejados: ' + bytes);
await this.sleep(4);
if (counter === 50) {
console.log('Stucked on ' + name + ' - Buffer Size: ' + this.buffer.length + ' BytesLeft: ' + this.bytesLeft() + ' BytesNeeded: ' + bytes);
}
}
resolve();
});
}
fill(data) {
this.buffer.fill(data, this.offset, this.offset + data.length);
this.offset += data.length;
}
fillMultiple(data, repeats) {
this.buffer.fill(data, this.offset, this.offset + data.length * repeats);
this.offset += data.length * repeats;
}
sleep(n): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(resolve, n);
});
}
}

10
qemu/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "."
},
"references": [
]
}

18
shared/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@socketcomputer/shared",
"version": "1.0.0",
"private": "true",
"description": "crusttest shared bits",
"type": "module",
"main": "dist/src/index.js",
"files": [
"dist"
],
"devDependencies": {},
"scripts": {
"build": "tsc"
},
"author": "",
"license": "ISC"
}

422
shared/src/Protocol.ts Normal file
View file

@ -0,0 +1,422 @@
import { Struct } from './Struct.js';
export enum MessageType {
// control
Key,
Mouse,
Turn,
// Display/audio
DisplayRect,
DisplaySize, // display changed size
// chat
Chat,
ChatHistory,
// user
AddUser,
RemUser,
// currently aren't used
RenUser,
Rename,
}
export enum MouseButtons {
Left = 1 << 0,
Right = 1 << 1,
Middle = 1 << 2,
WheelUp = 1 << 3,
WheelDn = 1 << 4
}
export enum RenameResult {
Ok,
InvalidUsername, /// This username is too long or otherwise invalid
UsernameTaken /// This username is taken on this slot.
}
export const kMaxUserNameLength = 24;
export const kMaxChatMessageLength = 150;
/// This is a 16-bit value, sort of a structure if you will.
/// 0x55 is the actual magic value.
/// 0x[vv] is the protocol version.
///
/// Any bumps to fields which are incompatible WILL require bumping the version field
/// to avoid older clients accidentally getting data they can't handle.
const kProtocolMagic = 0x5501;
export type ProtocolHeader = {
magic: number;
type: number;
payloadSize: number;
};
export type ChatMessageObject = {
username: string;
message: string;
};
// Messages
export type DeserializedMessageRoot = { type: MessageType };
export type KeyMessage = DeserializedMessageRoot & {
keysym: number;
pressed: boolean;
};
export type MouseMessage = DeserializedMessageRoot & {
x: number;
y: number;
buttons: MouseButtons; // Actually a bitmask
};
export type TurnMessage = DeserializedMessageRoot & {};
export type TurnServerMessage = DeserializedMessageRoot & {
time: number;
turnQueue: string[];
};
// This is using browser types for simplicity's sake
export type DisplayRectMessage = DeserializedMessageRoot & {
x: number;
y: number;
data: ArrayBuffer;
};
export type DisplaySizeMessage = DeserializedMessageRoot & {
width: number;
height: number;
};
export type ChatMessage = DeserializedMessageRoot & {
message: string;
};
export type ChatServerMessage = DeserializedMessageRoot & ChatMessageObject;
export type ChatHistoryMessage = DeserializedMessageRoot & {
history: ChatMessageObject[];
};
export type AddUserMessage = DeserializedMessageRoot & {
user: string;
};
export type RemUserMessage = DeserializedMessageRoot & {
user: string;
};
export type RenUserMessage = DeserializedMessageRoot & {
prevUsername: string;
newUsername: string;
};
export type RenameMessage = DeserializedMessageRoot & {
newUsername: string;
};
export type RenameServerMessage = DeserializedMessageRoot & {
result: RenameResult;
};
export type AnyMessage =
| KeyMessage
| MouseMessage
| TurnMessage
| TurnServerMessage
| DisplayRectMessage
| DisplaySizeMessage
| ChatMessage
| ChatServerMessage
| ChatHistoryMessage
| AddUserMessage
| RemUserMessage
| RenUserMessage
| RenameMessage
| RenameServerMessage;
export type DeserializedMessage = AnyMessage;
export const kProtocolHeaderSize = 8;
export class MessageEncoder {
private struct: Struct;
private buffer: ArrayBuffer;
Init(byteSize) {
this.buffer = new ArrayBuffer(byteSize + kProtocolHeaderSize);
this.struct = new Struct(this.buffer);
this.InitHeader();
}
SetKeyMessage(keysym, pressed) {
this.SetTypeCode(MessageType.Key);
this.struct.WriteU16(keysym);
this.struct.WriteU8(pressed == true ? 1 : 0);
}
SetMouseMessage(x, y, buttons: MouseButtons) {
this.SetTypeCode(MessageType.Mouse);
this.struct.WriteU16(x);
this.struct.WriteU16(y);
this.struct.WriteU8(buttons);
}
SetTurnMessage() {
this.SetTypeCode(MessageType.Turn);
}
SetTurnSrvMessage(ms, usersQueue) {
this.SetTypeCode(MessageType.Turn);
this.struct.WriteU32(ms);
this.struct.WriteArray(usersQueue, this.struct.WriteString);
}
SetDisplayRectMessage(x, y, buffer: ArrayBuffer) {
this.SetTypeCode(MessageType.DisplayRect);
this.struct.WriteU16(x);
this.struct.WriteU16(y);
this.struct.WriteBuffer(buffer);
}
SetDisplaySizeMessage(w, h) {
this.SetTypeCode(MessageType.DisplaySize);
this.struct.WriteU16(w);
this.struct.WriteU16(h);
}
SetChatMessage(msg) {
this.SetTypeCode(MessageType.Chat);
this.struct.WriteStringLen(msg, kMaxChatMessageLength);
}
SetChatSrvMessage(user: string, msg: string) {
this.SetTypeCode(MessageType.Chat);
this.struct.WriteStringLen(user, kMaxUserNameLength);
this.struct.WriteStringLen(msg, kMaxChatMessageLength);
}
SetChatHistoryMessage(history: ChatMessageObject[]) {
this.SetTypeCode(MessageType.ChatHistory);
this.struct.WriteArray(history, this.AddChatSrvMessage);
}
SetAddUserMessage(user: string) {
this.SetTypeCode(MessageType.AddUser);
this.struct.WriteString(user);
}
SetRemUserMessage(user: string) {
this.SetTypeCode(MessageType.AddUser);
this.struct.WriteString(user);
}
SetRenUserMessage(prevUsername: string, newUsername: string) {
this.SetTypeCode(MessageType.RenUser);
this.struct.WriteStringLen(prevUsername, kMaxUserNameLength);
this.struct.WriteStringLen(newUsername, kMaxUserNameLength);
}
SetRenameMessage(username: string) {
this.SetTypeCode(MessageType.Rename);
this.struct.WriteStringLen(username, kMaxUserNameLength);
}
SetRenameServerMessage(result: RenameResult) {
this.SetTypeCode(MessageType.Rename);
this.struct.WriteU8(result);
}
// Setup some stuff and then return the final message
Finish() {
let endOffset = this.struct.Tell();
this.struct.Seek(4); // seek to size offset
this.struct.WriteU32(endOffset - kProtocolHeaderSize);
this.struct.Seek(endOffset);
return this.buffer.slice(0, endOffset);
}
private InitHeader() {
this.struct.Seek(0);
this.struct.WriteU16(kProtocolMagic);
this.struct.WriteU16(0); // No message type yet
this.struct.WriteU32(0); // No payload size yet
}
private SetTypeCode(type: MessageType) {
let oldOff = this.struct.Tell();
this.struct.Seek(2); // seek to type offset
this.struct.WriteU16(type);
this.struct.Seek(oldOff);
}
// TODO rename to AddChatMessageObject. lazy
private AddChatSrvMessage(message: ChatMessageObject) {
this.struct.WriteStringLen(message.username, kMaxUserNameLength);
this.struct.WriteStringLen(message.message, kMaxChatMessageLength);
}
}
// goofy, but.. reentrancy!
class MessageDecoderExtContext {
public struct: Struct;
ReadChatSrvMessage() {
let msg: ChatMessageObject;
msg.username = this.struct.ReadStringLen(kMaxUserNameLength);
msg.message = this.struct.ReadStringLen(kMaxChatMessageLength);
}
}
export class MessageDecoder {
static async ReadMessage(buffer: ArrayBuffer, asClient: boolean): Promise<DeserializedMessage> {
return new Promise((res, rej) => {
MessageDecoder.ReadMessageSync(buffer, asClient, (err: Error, message: DeserializedMessage) => {
if (err) rej(err);
res(message);
});
});
}
private static ReadMessageSync(buffer: ArrayBuffer, asClient: boolean, callback: (err: Error, message:DeserializedMessage) => void) {
let struct = new Struct(buffer);
let context = new MessageDecoderExtContext();
context.struct = struct;
// Read and verify the header
let header: ProtocolHeader = {
magic: struct.ReadU16(),
type: struct.ReadU16() as MessageType,
payloadSize: struct.ReadU32()
};
if (header.magic !== kProtocolMagic) return callback(new Error('Invalid protocol message'), null);
if(header.payloadSize > buffer.byteLength)
return callback(new Error('invalid header'), null);
let message: DeserializedMessage = {
type: header.type
};
switch (header.type) {
case MessageType.Key:
if (asClient) {
return callback(new Error('unexpected server->client message'), null);
}
(message as KeyMessage).keysym = struct.ReadU16();
(message as KeyMessage).pressed = struct.ReadU8() == 1 ? true : false;
return callback(null, message);
break;
case MessageType.Mouse:
if (asClient) {
return callback(new Error('unexpected server->client message'), null);
}
(message as MouseMessage).x = struct.ReadU16();
(message as MouseMessage).y = struct.ReadU16();
(message as MouseMessage).buttons = struct.ReadU8() as MouseButtons;
return callback(null, message);
case MessageType.Turn:
if (asClient) {
// the server->client version of this message contains fields
(message as TurnServerMessage).time = struct.ReadU32();
(message as TurnServerMessage).turnQueue = struct.ReadArray(struct.ReadString);
}
return callback(null, message);
break;
case MessageType.DisplayRect:
if (asClient) {
(message as DisplayRectMessage).x = struct.ReadU16();
(message as DisplayRectMessage).y = struct.ReadU16();
(message as DisplayRectMessage).data = struct.ReadBuffer();
return callback(null, message);
} else {
return callback(new Error('unexpected client->server message'), null);
}
break;
case MessageType.DisplaySize:
if (asClient) {
(message as DisplaySizeMessage).width = struct.ReadU16();
(message as DisplaySizeMessage).height = struct.ReadU16();
} else {
return callback(new Error('unexpected client->server message'), null);
}
return callback(null, message);
break;
case MessageType.Chat:
if (asClient) {
(message as unknown as ChatMessageObject).username = struct.ReadStringLen(kMaxUserNameLength);
(message as unknown as ChatMessageObject).message = struct.ReadStringLen(kMaxChatMessageLength);
} else {
// the client->server version of this message only has the content
(message as ChatMessage).message = struct.ReadStringLen(kMaxChatMessageLength);
}
return callback(null, message);
break;
case MessageType.ChatHistory:
if (asClient) {
(message as ChatHistoryMessage).history = struct.ReadArray(context.ReadChatSrvMessage);
return callback(null, message);
} else {
return callback(new Error('unexpected client->server message'), null);
}
break;
case MessageType.AddUser:
if (asClient) {
(message as AddUserMessage).user = struct.ReadString();
return callback(null, message);
} else {
return callback(new Error('unexpected client->server message'), null);
}
case MessageType.RemUser:
if (asClient) {
(message as RemUserMessage).user = struct.ReadString();
return callback(null, message);
} else {
return callback(new Error('unexpected client->server message'), null);
}
break;
case MessageType.RenUser:
if (asClient) {
(message as RenUserMessage).prevUsername = struct.ReadStringLen(kMaxUserNameLength);
(message as RenUserMessage).newUsername = struct.ReadStringLen(kMaxUserNameLength);
return callback(null, message);
} else {
return callback(new Error('unexpected client->server message'), null);
}
break;
case MessageType.Rename:
if (asClient) {
(message as RenameServerMessage).result = struct.ReadU8() as RenameResult;
} else {
(message as RenameMessage).newUsername = struct.ReadStringLen(kMaxUserNameLength);
}
return callback(null, message);
break;
default:
return callback(new Error(`unknown type code ${header.type}`), null);
}
}
}

162
shared/src/Struct.ts Normal file
View file

@ -0,0 +1,162 @@
/// Helper class for reading structured binary data.
export class Struct {
private byteOffset = 0;
private buffer: ArrayBuffer;
private dv: DataView;
constructor(buffer: ArrayBuffer) {
this.dv = new DataView(buffer);
this.buffer = buffer;
}
Seek(off: number) {
// sanity checking should be added later
this.byteOffset = off;
}
Tell(): number {
return this.byteOffset;
}
// Reader functions
ReadArray(functor: () => any) {
let len = this.ReadU32();
let arr: any = [];
for (let i = 0; i < len; ++i) arr.push(functor.call(this));
return arr;
}
ReadString() {
let len = this.ReadU32();
let s = '';
for (let i = 0; i < len; ++i) s += String.fromCharCode(this.ReadU16());
return s;
}
ReadStringLen(maxStringLength) {
let stringLength = this.ReadU32();
let s = '';
if(maxStringLength > stringLength)
throw new Error(`string length ${stringLength} is past the max ${maxStringLength}`);
for (let i = 0; i < stringLength; ++i)
s += String.fromCharCode(this.ReadU16());
return s;
}
ReadU8() {
let i = this.dv.getUint8(this.byteOffset);
this.byteOffset++;
return i;
}
ReadS8() {
let i = this.dv.getInt8(this.byteOffset);
this.byteOffset++;
return i;
}
ReadU16() {
let i = this.dv.getUint16(this.byteOffset, false);
this.byteOffset += 2;
return i;
}
ReadS16() {
let i = this.dv.getInt16(this.byteOffset, false);
this.byteOffset += 2;
return i;
}
ReadU32() {
let i = this.dv.getUint32(this.byteOffset, false);
this.byteOffset += 4;
return i;
}
ReadS32() {
let i = this.dv.getInt32(this.byteOffset, false);
this.byteOffset += 4;
return i;
}
ReadBuffer() {
let count = this.ReadU32();
let buf = this.buffer.slice(this.byteOffset, this.byteOffset + count);
this.byteOffset += count;
return buf;
}
// Writer functions
// TODO: let these grow!. we can do that by just allocating a new buffer
// then copying
// Add an array with a fixed type
WriteArray(arr, functor) {
this.WriteU32(arr.length);
for (let elem of arr) functor.call(this, elem);
}
// Add a pascal UTF-16 string.
WriteString(str) {
this.WriteU32(str.length);
for (let i in str) this.WriteU16(str.charCodeAt(i));
}
// writes a string with a max length.
// will trim strings that are too long
WriteStringLen(str, len) {
let length = len;
// pick the string length, but ONLY
// if it is smaller or equal to our
// max bound lenfth
if(len <= str.length)
length = str.length;
this.WriteU32(length);
for (let i = 0; i < length; ++i)
this.WriteU16(str.charCodeAt(i));
}
WriteU8(i) {
this.dv.setUint8(this.byteOffset, i);
this.byteOffset++;
}
WriteS8(i) {
this.dv.setInt8(this.byteOffset, i);
this.byteOffset++;
}
WriteU16(i) {
this.dv.setUint16(this.byteOffset, i, false);
this.byteOffset += 2;
}
WriteS16(i) {
this.dv.setInt16(this.byteOffset, i, false);
this.byteOffset += 2;
}
WriteU32(i) {
this.dv.setUint32(this.byteOffset, i, false);
this.byteOffset += 4;
}
WriteS32(i) {
this.dv.setInt32(this.byteOffset, i, false);
this.byteOffset += 4;
}
WriteBuffer(buffer: ArrayBuffer) {
this.WriteU32(buffer.byteLength);
for (let i = 0; i < buffer.byteLength; ++i) this.WriteU8(buffer[i]);
}
}

View file

@ -0,0 +1,9 @@
import { kMaxUserNameLength, RenameResult } from './Protocol.js';
// This function simply validates that the username is ok to set. if it's not
// then we don't even bother checking if the user already exists, in the backend
// code we'll just give up.
export function ValidateUsername(username: string): RenameResult {
if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(username) || username.length > kMaxUserNameLength || username.trim().length < 3) return RenameResult.InvalidUsername;
return RenameResult.Ok;
}

2
shared/src/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from './Protocol.js';
export * from './UsernameValidator.js';

View file

@ -0,0 +1,77 @@
import { MessageDecoder, MessageEncoder } from '../src/Protocol.js';
function buf2hex(buffer) {
// buffer is an ArrayBuffer
return [...new Uint8Array(buffer)].map((x) => x.toString(16).padStart(2, '0')).join(' ');
}
async function decodeMessage(buf, isClient) {
// Let's try decoding this message
console.log('Got message:', await MessageDecoder.ReadMessage(buf, isClient));
}
function encodeMessage(f: (encoder: MessageEncoder) => void) {
var encoder = new MessageEncoder();
encoder.Init(4096);
f(encoder);
var buf = encoder.Finish();
console.log('the buffer of this message:', buf2hex(buf));
return buf;
}
(async () => {
console.log('[CLIENT TESTS]');
await decodeMessage(
encodeMessage((encoder) => {
encoder.SetChatMessage('Hi, CrustTest World!');
}),
false
);
await decodeMessage(
encodeMessage((encoder) => {
encoder.SetTurnMessage();
}),
false
);
console.log('[END CLIENT TESTS]');
console.log('[SERVER TESTS]');
// server->client tests
await decodeMessage(
encodeMessage((encoder) => {
encoder.SetChatSrvMessage('tester19200', 'Hi, CrustTest World!');
}),
true
);
await decodeMessage(
encodeMessage((encoder) => {
encoder.SetTurnSrvMessage(1000, ['tester19200', 'tester59340', 'tester10000', 'tester1337']);
}),
true
);
console.log('[END SERVER TESTS]');
console.log('[BEGIN THINGS THAT SHOULD INTENTIONALLY NOT WORK]');
try {
let r = await decodeMessage(
encodeMessage((encoder) => {
encoder.SetDisplaySizeMessage(1000, 1000);
}),
false // the client wouldn't usually send a display size message..
);
console.log(`wait uhh.. how'd that work?`, r);
} catch (err) {
console.log(`ok cool, that returns this error: ${err}`);
}
console.log('[END THINGS THAT SHOULD INTENTIONALLY NOT WORK]');
})();

8
shared/tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "."
}
}

13
tsconfig-base.json Normal file
View file

@ -0,0 +1,13 @@
// This is the base tsconfig
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2020",
"moduleResolution": "Node",
"declaration": true,
"sourceMap": true,
// more compiler options?
"strict": false, // maybe enable later..?
}
}

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"files": [],
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmitOnError": true,
"skipLibCheck": true,
"baseUrl": "./",
"paths": {
"@socketcomputer/shared*": [ "shared/src/*" ],
"@socketcomputer/backend*": [ "backend/src/*" ],
"@socketcomputer/qemu*": [ "qemu/src/*" ]
},
},
"exclude": [
"node_modules",
"dist"
],
"references": [
{ "path": "./backend" },
{ "path": "./qemu" },
{ "path": "./shared" },
]
}

20
webapp/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "@socketcomputer/webapp",
"version": "1.0.0",
"private": "true",
"description": "",
"scripts": {
"serve": "parcel src/index.html",
"build": "parcel build --dist-dir dist --public-url '.' src/index.html"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@parcel/transformer-sass": "^2.12.0",
"parcel": "^2.12.0",
"typescript": "^5.4.3"
},
"dependencies": {
"nanoevents": "^9.0.0"
}
}

107
webapp/src/css/main.css Normal file
View file

@ -0,0 +1,107 @@
html, body {
margin: 0;
padding: 0;
}
body {
width: 100%;
height: 100%;
background-color: #fff;
font-family: Helvetica, sans-serif;
}
#window-chrome {
display: none;
}
img {
user-drag: none;
-moz-user-drag: none;
-webkit-user-drag: none;
}
#xp-window {
/*cursor: none; /* let windows do that ;) */
background: url(../../static/macbook.png) no-repeat center;
background-size: 100% 100%;
width: 690px;
height: 460px;
margin: auto;
text-align: center;
}
.xp-image, .xp-image img, .xp-image canvas {
width: 500px;
height: 375px;
display: inline-block;
}
.xp-image {
margin-top: 38px;
}
.focused img, .focused canvas {
box-shadow: 0px 0px 9px 0px rgba(45, 213, 255, 0.75);
-moz-box-shadow: 0px 0px 9px 0px rgba(45, 213, 255, 0.75);
-webkit-box-shadow: 0px 0px 9px 0px rgba(45, 213, 255, 0.75);
}
.waiting img, .waiting canvas {
box-shadow: 0px 0px 9px 0px rgba(242, 255, 63, 0.75);
-moz-box-shadow: 0px 0px 9px 0px rgba(242, 255, 63, 0.75);
-webkit-box-shadow: 0px 0px 9px 0px rgba(242, 255, 63, 0.75);
}
.turn-timer {
font-size: 13px;
color: #999;
padding: 10px 0;
text-align: center;
}
.user-count-wrapper {
text-align: center;
position: absolute;
top: 10px;
}
.user-count {
background-color: #FFFF66;
padding: 4px 12px;
font-size: 12px;
color: #906F95;
}
.count {
color: #61226B;
padding-right: 12px;
font-weight: 500;
}
@media only screen and (min-width : 320px) and (max-width : 480px), (max-width : 568px) {
#xp-window {
background: none;
height: auto;
width: auto;
}
.xp-image {
padding: 5px;
background: #000;
}
.xp-image, .xp-image img, .xp-image canvas {
width: 310px;
margin-top: 0;
height: 234px;
display: block;
}
.user-count-wrapper {
position: relative;
}
.count {
padding: 0;
}
}

34
webapp/src/index.html Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>socket.io makes computers</title>
<!-- not anymore it doesn't -->
<link rel="stylesheet" href="css/main.css" />
<meta name="viewport" content="width=device-width, user-scalable=no">
<script type="module" src="index.ts"></script>
</head>
<body>
<p style="text-align: center; font-size: 12px; color: #999;">Click the screen to request a turn and control the computer!</p>
<div id="xp-window">
<div class="xp-image">
<canvas id="xp-canvas" width="800" height="600"></canvas>
</div>
</div>
<div style="height: 15px;" class="turn-timer"></div>
<div class="user-count-wrapper">
<span class="user-count">
Users logged in: <span id="count" class="count"></span>
</span>
</div>
<p style="margin-top: 30px; text-align: center; font-size: 11px; color: #999;">
To avoid misusage, we re-snapshot the computer every 15 minutes! <a href="TBD: Check cngit lol">[Source code]</a>
</p>
<p style="margin-top: 30px; text-align: center; font-size: 11px; color: #666;"><small>Not</small> Powered by <a href="http://socket.io">Socket.IO (Don't use this!!)</a></p>
</body>
</html>

124
webapp/src/index.ts Normal file
View file

@ -0,0 +1,124 @@
import * as Shared from '@socketcomputer/shared';
type user = {
username: string
queuePos: number
};
// client for
class client {
private websocket: WebSocket = null;
private url = "";
private userList = new Array<user>();
private canvas:HTMLCanvasElement = null;
private canvasCtx : CanvasRenderingContext2D = null;
constructor(url: string, canvas: HTMLCanvasElement) {
this.url = url
this.canvas = canvas;
this.canvasCtx = canvas.getContext('2d');
}
public connect() {
this.websocket = new WebSocket(this.url);
this.websocket.binaryType = 'arraybuffer'; // its 2024 people.
this.websocket.addEventListener('open', this.onWsOpen.bind(this));
this.websocket.addEventListener('message', this.onWsMessage.bind(this));
this.websocket.addEventListener('close', this.onWsClose.bind(this));
}
private onWsOpen() {
console.log("client WS OPEN!!");
}
private async onWsMessage(e: MessageEvent) {
// no guacmoale or shity fucking sockret io here.
if(typeof(e.data) == "string")
return;
try {
let message = await Shared.MessageDecoder.ReadMessage(e.data as ArrayBuffer, true);
switch(message.type) {
case Shared.MessageType.DisplaySize:
this.resizeDisplay((message as Shared.DisplaySizeMessage));
break;
case Shared.MessageType.DisplayRect:
this.drawRects((message as Shared.DisplayRectMessage));
break;
case Shared.MessageType.AddUser:
this.addUser((message as Shared.AddUserMessage));
break;
case Shared.MessageType.RemUser:
this.remUser((message as Shared.RemUserMessage));
break;
default:
console.log(`Unhandled message type ${message.type}`);
break;
}
} catch(e) {
console.log("Is not works..", e)
}
}
private onWsClose() {
// backoff?
console.log("FUCK YOU FUCKING FUCKER GOOGLE.");
this.websocket.removeEventListener("open", this.onWsOpen);
this.websocket.removeEventListener("message", this.onWsMessage);
this.websocket.removeEventListener("close", this.onWsClose);
this.websocket = null;
}
private resizeDisplay(message: Shared.DisplaySizeMessage) {
console.log("resizes to", message.width, message.height)
this.canvas.width = message.width;
this.canvas.height = message.height;
}
private updateUserCount() {
let elem : HTMLSpanElement = document.getElementById("count") as HTMLSpanElement;
elem.innerText = this.userList.length.toString();
}
private addUser(message: Shared.AddUserMessage) {
this.userList.push({
username: message.user,
queuePos: -1
});
this.updateUserCount();
}
private remUser(message: Shared.RemUserMessage) {
let index = this.userList.findIndex((u) => {
return u.username == message.user;
});
if(index !== -1)
this.userList.splice(index, 1);
this.updateUserCount();
}
private drawRects(message: Shared.DisplayRectMessage) {
let blob = new Blob([message.data]);
createImageBitmap(blob)
.then((image) => {
this.canvasCtx.drawImage(image, message.x, message.y);
}).catch((err) => {
console.error(`Fuck error decode rect...`, err);
});
}
}
let globalclient = null;
document.addEventListener("DOMContentLoaded", async () => {
globalclient = new client("ws://127.0.0.1:4050", document.getElementById("xp-canvas") as HTMLCanvasElement);
globalclient.connect();
})

BIN
webapp/static/macbook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

19
webapp/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"moduleResolution": "Node",
"target": "ES6",
"outDir": "./dist",
"baseUrl": "."
},
"references": [
{ "path": "../shared" }
]
}

2765
yarn.lock Normal file

File diff suppressed because it is too large Load diff