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 { 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 { 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(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(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); }