initial commit
This commit is contained in:
commit
071b531679
45 changed files with 6874 additions and 0 deletions
8
.editorconfig
Normal file
8
.editorconfig
Normal 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
7
.gitignore
vendored
Normal 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
3
.prettierignore
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
*.md
|
||||||
|
*.json
|
20
.prettierrc.json
Normal file
20
.prettierrc.json
Normal 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
20
LICENSE
Normal 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
29
README.md
Normal 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
24
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
46
backend/src/ExtendableTimer.ts
Normal file
46
backend/src/ExtendableTimer.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
70
backend/src/SlotQemuDefs.ts
Normal file
70
backend/src/SlotQemuDefs.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
395
backend/src/SocketComputerServer.ts
Normal file
395
backend/src/SocketComputerServer.ts
Normal 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
8
backend/src/index.ts
Normal 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
11
backend/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared" }
|
||||||
|
]
|
||||||
|
}
|
22
package.json
Normal file
22
package.json
Normal 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
19
qemu/package.json
Normal 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
143
qemu/src/QemuDisplay.ts
Normal 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
33
qemu/src/QemuUtil.ts
Normal 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
286
qemu/src/QemuVM.ts
Normal 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
135
qemu/src/QmpClient.ts
Normal 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
3
qemu/src/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './QemuDisplay.js';
|
||||||
|
export * from './QemuUtil.js';
|
||||||
|
export * from './QemuVM.js';
|
21
qemu/src/rfb/LICENSE
Normal file
21
qemu/src/rfb/LICENSE
Normal 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
10
qemu/src/rfb/README.md
Normal 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
885
qemu/src/rfb/client.ts
Normal 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
46
qemu/src/rfb/constants.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
29
qemu/src/rfb/decoders/copyrect.ts
Normal file
29
qemu/src/rfb/decoders/copyrect.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
241
qemu/src/rfb/decoders/hextile.ts
Normal file
241
qemu/src/rfb/decoders/hextile.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
qemu/src/rfb/decoders/raw.ts
Normal file
54
qemu/src/rfb/decoders/raw.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
357
qemu/src/rfb/decoders/zrle.ts
Normal file
357
qemu/src/rfb/decoders/zrle.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
132
qemu/src/rfb/socketbuffer.ts
Normal file
132
qemu/src/rfb/socketbuffer.ts
Normal 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
10
qemu/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
]
|
||||||
|
}
|
18
shared/package.json
Normal file
18
shared/package.json
Normal 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
422
shared/src/Protocol.ts
Normal 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
162
shared/src/Struct.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
9
shared/src/UsernameValidator.ts
Normal file
9
shared/src/UsernameValidator.ts
Normal 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
2
shared/src/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Protocol.js';
|
||||||
|
export * from './UsernameValidator.js';
|
77
shared/test/Protocol.test.ts
Normal file
77
shared/test/Protocol.test.ts
Normal 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
8
shared/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
}
|
||||||
|
}
|
13
tsconfig-base.json
Normal file
13
tsconfig-base.json
Normal 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
27
tsconfig.json
Normal 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
20
webapp/package.json
Normal 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
107
webapp/src/css/main.css
Normal 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
34
webapp/src/index.html
Normal 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
124
webapp/src/index.ts
Normal 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
BIN
webapp/static/macbook.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 122 KiB |
19
webapp/tsconfig.json
Normal file
19
webapp/tsconfig.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in a new issue