webapp/src/ts/protocol/CollabVMClient.ts
2024-02-04 13:02:45 -05:00

492 lines
17 KiB
TypeScript

import {createNanoEvents } from "nanoevents";
import * as Guacutils from './Guacutils.js';
import VM from "./VM.js";
import { User } from "./User.js";
import { Permissions, Rank } from "./Permissions.js";
import TurnStatus from "./TurnStatus.js";
import Mouse from "./mouse.js";
import GetKeysym from '../keyboard.js';
import VoteStatus from "./VoteStatus.js";
import MuteState from "./MuteState.js";
export default class CollabVMClient {
// Fields
private socket : WebSocket;
canvas : HTMLCanvasElement;
private ctx : CanvasRenderingContext2D;
private url : string;
private connectedToVM : boolean = false;
private users : User[] = [];
private username : string | null = null;
private mouse : Mouse = new Mouse();
private rank : Rank = Rank.Unregistered;
private perms : Permissions = new Permissions(0);
private voteStatus : VoteStatus | null = null;
private node : string | null = null;
// events that are used internally and not exposed
private emitter;
// public events
private publicEmitter;
constructor(url : string) {
// Save the URL
this.url = url;
// Create the events
this.emitter = createNanoEvents();
this.publicEmitter = createNanoEvents();
// Create the canvas
this.canvas = document.createElement('canvas');
// Set tab index so it can be focused
this.canvas.tabIndex = -1;
// Get the 2D context
this.ctx = this.canvas.getContext('2d')!;
// Bind canvas click
this.canvas.addEventListener('click', e => {
if (this.users.find(u => u.username === this.username)?.turn === -1)
this.turn(true);
});
// Bind keyboard and mouse
this.canvas.addEventListener('mousedown', (e : MouseEvent) => {
if (this.users.find(u => u.username === this.username)?.turn === -1 && this.rank !== Rank.Admin) return;
this.mouse.processEvent(e, true);
this.sendmouse(this.mouse.x, this.mouse.y, this.mouse.makeMask());
}, {
capture: true
});
this.canvas.addEventListener('mouseup', (e : MouseEvent) => {
if (this.users.find(u => u.username === this.username)?.turn === -1 && this.rank !== Rank.Admin) return;
this.mouse.processEvent(e, false);
this.sendmouse(this.mouse.x, this.mouse.y, this.mouse.makeMask());
}, {
capture: true
});
this.canvas.addEventListener('mousemove', (e : MouseEvent) => {
if (this.users.find(u => u.username === this.username)?.turn === -1 && this.rank !== Rank.Admin) return;
this.mouse.processEvent(e, null);
this.sendmouse(this.mouse.x, this.mouse.y, this.mouse.makeMask());
}, {
capture: true
});
this.canvas.addEventListener('keydown', (e : KeyboardEvent) => {
e.preventDefault();
if (this.users.find(u => u.username === this.username)?.turn === -1 && this.rank !== Rank.Admin) return;
var keysym = GetKeysym(e.keyCode, e.key, e.location);
if (keysym === null) return;
this.key(keysym, true);
}, {
capture: true
});
this.canvas.addEventListener('keyup', (e : KeyboardEvent) => {
e.preventDefault();
if (this.users.find(u => u.username === this.username)?.turn === -1 && this.rank !== Rank.Admin) return;
var keysym = GetKeysym(e.keyCode, e.key, e.location);
if (keysym === null) return;
this.key(keysym, false);
}, {
capture: true
});
this.canvas.addEventListener('contextmenu', e => e.preventDefault());
// Create the WebSocket
this.socket = new WebSocket(url, "guacamole");
// Add the event listeners
this.socket.addEventListener('open', () => this.onOpen());
this.socket.addEventListener('message', (event) => this.onMessage(event));
this.socket.addEventListener('close', () => this.publicEmitter.emit('close'));
}
// Fires when the WebSocket connection is opened
private onOpen() {
this.publicEmitter.emit('open');
}
// Fires on WebSocket message
private onMessage(event : MessageEvent) {
var msgArr : string[];
try {
msgArr = Guacutils.decode(event.data);
} catch (e) {
console.error(`Server sent invalid message (${e})`);
return;
}
this.publicEmitter.emit('message', ...msgArr);
switch (msgArr[0]) {
case "nop": {
// Send a NOP back
this.send("nop");
break;
}
case "list": {
// pass msgarr to the emitter for processing by list()
this.emitter.emit('list', msgArr.slice(1));
break;
}
case "connect": {
this.connectedToVM = msgArr[1] === "1";
this.emitter.emit('connect', this.connectedToVM);
break;
}
case "size": {
if (msgArr[1] !== "0") return;
this.canvas.width = parseInt(msgArr[2]);
this.canvas.height = parseInt(msgArr[3]);
break;
}
case "png": {
// Despite the opcode name, this is actually JPEG, because old versions of the server used PNG and yknow backwards compatibility
var img = new Image();
img.addEventListener('load', () => {
this.ctx.drawImage(img, parseInt(msgArr[3]), parseInt(msgArr[4]));
});
img.src = "data:image/jpeg;base64," + msgArr[5];
break;
}
case "chat": {
for (var i = 1; i < msgArr.length; i += 2) {
this.publicEmitter.emit('chat', msgArr[i], msgArr[i + 1]);
}
break;
}
case "adduser": {
for (var i = 2; i < msgArr.length; i += 2) {
var _user = this.users.find(u => u.username === msgArr[i]);
if (_user !== undefined) {
_user.rank = parseInt(msgArr[i + 1]) as Rank;
} else {
_user = new User(msgArr[i], parseInt(msgArr[i + 1]) as Rank);
this.users.push(_user);
}
this.publicEmitter.emit('adduser', _user);
}
break;
}
case "remuser": {
for (var i = 2; i < msgArr.length; i++) {
var _user = this.users.find(u => u.username === msgArr[i]);
if (_user === undefined) continue;
this.users.splice(this.users.indexOf(_user), 1);
this.publicEmitter.emit('remuser', _user);
}
}
case "rename": {
var selfrename = false;
var oldusername : string | null = null;
// We've been renamed
if (msgArr[1] === "0") {
selfrename = true;
oldusername = this.username;
// msgArr[2] is the status of the rename
// Anything other than 0 is an error, however the server will still rename us to a guest name
switch (msgArr[2]) {
case "1":
// The username we wanted was taken
this.publicEmitter.emit('renamestatus', 'taken');
break;
case "2":
// The username we wanted was invalid
this.publicEmitter.emit('renamestatus', 'invalid');
break;
case "3":
// The username we wanted is blacklisted
this.publicEmitter.emit('renamestatus', 'blacklisted');
break;
}
this.username = msgArr[3];
}
else oldusername = msgArr[2];
var _user = this.users.find(u => u.username === oldusername);
if (_user) {
_user.username = msgArr[3];
}
this.publicEmitter.emit('rename', oldusername, msgArr[3], selfrename);
break;
}
case "turn": {
// Reset all turn data
for (var user of this.users) user.turn = -1;
var queuedUsers = parseInt(msgArr[2]);
if (queuedUsers === 0) {
this.publicEmitter.emit('turn', {
user: null,
queue: [],
turnTime: null,
queueTime: null,
} as TurnStatus);
return;
}
var currentTurn = this.users.find(u => u.username === msgArr[3])!;
currentTurn.turn = 0;
var queue : User[] = [];
if (queuedUsers > 1) {
for (var i = 1; i < queuedUsers; i++) {
var user = this.users.find(u => u.username === msgArr[i+3])!;
queue.push(user);
user.turn = i;
}
}
this.publicEmitter.emit('turn', {
user: currentTurn,
queue: queue,
turnTime: currentTurn.username === this.username ? parseInt(msgArr[1]) : null,
queueTime: queue.some(u => u.username === this.username) ? parseInt(msgArr[msgArr.length - 1]) : null,
} as TurnStatus)
break;
}
case "vote": {
switch (msgArr[1]) {
case "0":
// Vote started
case "1":
// Vote updated
var timeToEnd = parseInt(msgArr[2]);
var yesVotes = parseInt(msgArr[3]);
var noVotes = parseInt(msgArr[4]);
// Some server implementations dont send data for status 0, and some do
if (Number.isNaN(timeToEnd) || Number.isNaN(yesVotes) || Number.isNaN(noVotes)) return;
this.voteStatus = {
timeToEnd: timeToEnd,
yesVotes: yesVotes,
noVotes: noVotes,
};
this.publicEmitter.emit('vote', this.voteStatus);
break;
case "2":
// Vote ended
this.voteStatus = null;
this.publicEmitter.emit('voteend');
break;
case "3":
// Cooldown
this.publicEmitter.emit('votecd', parseInt(msgArr[2]));
break;
}
}
case "admin": {
switch (msgArr[1]) {
case "0": {
// Login
switch (msgArr[2]) {
case "0":
this.publicEmitter.emit('badpw');
return;
case "1":
this.perms = new Permissions(65535);
this.rank = Rank.Admin;
break;
case "2":
this.perms = new Permissions(parseInt(msgArr[3]));
this.rank = Rank.Moderator;
break;
}
this.publicEmitter.emit('login', this.rank, this.perms);
break;
}
case "19": {
// IP
this.emitter.emit('ip', msgArr[2], msgArr[3]);
break;
}
case "2": {
// QEMU
this.emitter.emit('qemu', msgArr[2]);
}
}
}
}
}
// Sends a message to the server
send(...args : string[]) {
this.socket.send(Guacutils.encode(...args));
}
// Get a list of all VMs
list() : Promise<VM[]> {
return new Promise((res, rej) => {
var u = this.emitter.on('list', (list : string[]) => {
u();
var vms : VM[] = [];
for (var i = 0; i < list.length; i += 3) {
var th = new Image();
th.src = "data:image/jpeg;base64," + list[i + 2];
vms.push({
url: this.url,
id: list[i],
displayName: list[i + 1],
thumbnail: th,
});
}
res(vms);
});
this.send("list");
});
}
// Connect to a node
connect(id : string, username : string | null = null) : Promise<boolean> {
return new Promise(res => {
var u = this.emitter.on('connect', (success : boolean) => {
u();
res(success);
});
if (username === null) this.send("rename");
else this.send("rename", username);
this.send("connect", id);
this.node = id;
})
}
// Close the connection
close() {
this.connectedToVM = false;
if (this.socket.readyState === WebSocket.OPEN) this.socket.close();
}
// Get users
getUsers() : User[] {
// Return a copy of the array
return this.users.slice();
}
// Send a chat message
chat(message : string) {
this.send("chat", message);
}
// Rename
rename(username : string | null = null) {
if (username) this.send("rename", username);
else this.send("rename");
}
// Take or drop turn
turn(taketurn : boolean) {
this.send("turn", taketurn ? "1" : "0");
}
// Send mouse instruction
sendmouse(x : number, y : number, mask : number) {
this.send("mouse", x.toString(), y.toString(), mask.toString());
}
// Send key
key(keysym : number, down : boolean) {
this.send("key", keysym.toString(), down ? "1" : "0");
}
// Get vote status
getVoteStatus() : VoteStatus | null {
return this.voteStatus;
}
// Start a vote, or vote
vote(vote : boolean) {
this.send("vote", vote ? "1" : "0");
}
// Try to login using the specified password
login(password : string) {
this.send("admin", "2", password);
}
/* Admin commands */
// Restore
restore() {
if (!this.node) return;
this.send("admin", "8", this.node!);
}
// Reboot
reboot() {
if (!this.node) return;
this.send("admin", "10", this.node!);
}
// Clear turn queue
clearQueue() {
if (!this.node) return;
this.send("admin", "17", this.node!);
}
// Bypass turn
bypassTurn() {
this.send("admin", "20");
}
// End turn
endTurn(user : string) {
this.send("admin", "16", user);
}
// Ban
ban(user : string) {
this.send("admin", "12", user);
}
// Kick
kick(user : string) {
this.send("admin", "15", user);
}
// Rename user
renameUser(oldname : string, newname : string) {
this.send("admin", "18", oldname, newname);
}
// Mute user
mute(user : string, state : MuteState) {
this.send("admin", "14", user, state.toString());
}
// Grab IP
getip(user : string) {
if (this.users.find(u => u.username === user) === undefined) return false;
return new Promise<string>(res => {
var u = this.emitter.on('ip', (username : string, ip : string) => {
if (username !== user) return;
u();
res(ip);
})
this.send("admin", "19", user);
});
}
// QEMU Monitor
qemuMonitor(cmd : string) {
return new Promise<string>(res => {
var u = this.emitter.on('qemu', output => {
u();
res(output);
})
this.send("admin", "5", this.node!, cmd);
});
}
// XSS
xss(msg : string) {
this.send("admin", "21", msg);
}
// Force vote
forceVote(result : boolean) {
this.send("admin", "13", result ? "1" : "0");
}
// Toggle turns
turns(enabled : boolean) {
this.send("admin", "22", enabled ? "1" : "0");
}
// Indefinite turn
indefiniteTurn() {
this.send("admin", "23");
}
// Hide screen
hideScreen(hidden : boolean) {
this.send("admin", "24", hidden ? "1" : "0");
}
on = (event : string | number, cb: (...args: any) => void) => this.publicEmitter.on(event, cb);
}