import { createNanoEvents, Emitter, DefaultEvents, Unsubscribe } from 'nanoevents'; import * as Guacutils from './Guacutils.js'; import VM from './VM.js'; import { User } from './User.js'; import { AdminOpcode, 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'; import { StringLike } from '../StringLike.js'; export interface CollabVMClientEvents { //open: () => void; close: () => void; message: (...args: string[]) => void; // Protocol stuff chat: (username: string, message: string) => void; adduser: (user: User) => void; remuser: (user: User) => void; renamestatus: (status: 'taken' | 'invalid' | 'blacklisted') => void; turn: (status: TurnStatus) => void; rename: (oldUsername: string, newUsername: string, selfRename: boolean) => void; vote: (status: VoteStatus) => void; voteend: () => void; votecd: (coolDownTime: number) => void; badpw: () => void; login: (rank: Rank, perms: Permissions) => void; // Auth stuff auth: (server: string) => void; accountlogin: (success: boolean) => void; } // types for private emitter interface CollabVMClientPrivateEvents { open: () => void; list: (listEntries: string[]) => void; connect: (connectedToVM: boolean) => void; ip: (username: string, ip: string) => void; qemu: (qemuResponse: string) => void; } export default class CollabVMClient { // Fields private socket: WebSocket; canvas: HTMLCanvasElement; // A secondary canvas that is not scaled unscaledCanvas: HTMLCanvasElement; canvasScale : { width : number, height : number } = { width: 0, height: 0 }; actualScreenSize : { width : number, height : number } = { width: 0, height: 0 }; private unscaledCtx: CanvasRenderingContext2D; 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; private auth: boolean = false; // events that are used internally and not exposed private internalEmitter: Emitter; // public events private publicEmitter: Emitter; private unsubscribeCallbacks: Array = []; constructor(url: string) { // Save the URL this.url = url; // Create the events this.internalEmitter = createNanoEvents(); this.publicEmitter = createNanoEvents(); // Create the canvas this.canvas = document.createElement('canvas'); this.unscaledCanvas = 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')!; this.unscaledCtx = this.unscaledCanvas.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.initFromMouseEvent(e); 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.initFromMouseEvent(e); 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.initFromMouseEvent(e); 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; let 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; let keysym = GetKeysym(e.keyCode, e.key, e.location); if (keysym === null) return; this.key(keysym, false); }, { capture: true } ); this.canvas.addEventListener( 'wheel', (ev: WheelEvent) => { ev.preventDefault(); if (this.users.find((u) => u.username === this.username)?.turn === -1 && this.rank !== Rank.Admin) return; this.mouse.initFromWheelEvent(ev); this.sendmouse(this.mouse.x, this.mouse.y, this.mouse.makeMask()); // this is a very, very ugly hack but it seems to work so /shrug if (this.mouse.scrollUp) this.mouse.scrollUp = false; else if (this.mouse.scrollDown) this.mouse.scrollDown = false; this.sendmouse(this.mouse.x, this.mouse.y, this.mouse.makeMask()); }, { capture: true } ); window.addEventListener('resize', (e) => this.onWindowResize(e)); 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.internalEmitter.emit('open'); } // Fires on WebSocket message private onMessage(event: MessageEvent) { let 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.internalEmitter.emit('list', msgArr.slice(1)); break; } case 'connect': { this.connectedToVM = msgArr[1] === '1'; this.internalEmitter.emit('connect', this.connectedToVM); break; } case 'size': { if (msgArr[1] !== '0') return; this.recalculateCanvasScale(parseInt(msgArr[2]), parseInt(msgArr[3])); this.unscaledCanvas.width = this.actualScreenSize.width; this.unscaledCanvas.height = this.actualScreenSize.height; this.canvas.width = this.canvasScale.width; this.canvas.height = this.canvasScale.height; break; } case 'png': { // Despite the opcode name, this is actually JPEG, because old versions of the server used PNG and yknow backwards compatibility let img = new Image(); var x = parseInt(msgArr[3]); var y = parseInt(msgArr[4]); img.addEventListener('load', () => { if (this.actualScreenSize.width !== this.canvasScale.width || this.actualScreenSize.height !== this.canvasScale.height) this.unscaledCtx.drawImage(img, x, y); // Scale the image to the canvas this.ctx.drawImage(img, 0, 0, img.width, img.height, (x / this.actualScreenSize.width) * this.canvas.width, (y / this.actualScreenSize.height) * this.canvas.height, (img.width / this.actualScreenSize.width) * this.canvas.width, (img.height / this.actualScreenSize.height) * this.canvas.height ); }); img.src = 'data:image/jpeg;base64,' + msgArr[5]; break; } case 'chat': { for (let i = 1; i < msgArr.length; i += 2) { this.publicEmitter.emit('chat', msgArr[i], msgArr[i + 1]); } break; } case 'adduser': { for (let i = 2; i < msgArr.length; i += 2) { let _user = this.users.find((u) => u.username === msgArr[i]); if (_user !== undefined) { _user.rank = parseInt(msgArr[i + 1]); } else { _user = new User(msgArr[i], parseInt(msgArr[i + 1])); this.users.push(_user); } this.publicEmitter.emit('adduser', _user); } break; } case 'remuser': { for (let i = 2; i < msgArr.length; i++) { let _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': { let selfrename = false; let 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]; let _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 (let user of this.users) user.turn = -1; let queuedUsers = parseInt(msgArr[2]); if (queuedUsers === 0) { this.publicEmitter.emit('turn', { user: null, queue: [], turnTime: null, queueTime: null }); return; } let currentTurn = this.users.find((u) => u.username === msgArr[3])!; currentTurn.turn = 0; let queue: User[] = []; if (queuedUsers > 1) { for (let i = 1; i < queuedUsers; i++) { let 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 }); break; } case 'vote': { switch (msgArr[1]) { case '0': // Vote started case '1': // Vote updated let timeToEnd = parseInt(msgArr[2]); let yesVotes = parseInt(msgArr[3]); let 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; } } // auth stuff case 'auth': { this.publicEmitter.emit('auth', msgArr[1]); this.auth = true; break; } case 'login': { if (msgArr[1] === "1") { this.rank = Rank.Registered; this.publicEmitter.emit('login', Rank.Registered, new Permissions(0)); } this.publicEmitter.emit('accountlogin', msgArr[1] === "1"); break; } case 'admin': { switch (msgArr[1]) { case '0': { // Login switch (msgArr[2]) { case '0': this.publicEmitter.emit('badpw'); return; case '1': this.perms.set(65535); this.rank = Rank.Admin; break; case '3': this.perms.set(parseInt(msgArr[3])); this.rank = Rank.Moderator; break; } this.publicEmitter.emit('login', this.rank, this.perms); break; } case '19': { // IP this.internalEmitter.emit('ip', msgArr[2], msgArr[3]); break; } case '2': { // QEMU this.internalEmitter.emit('qemu', msgArr[2]); break; } } } } } private onWindowResize(e: Event) { if (!this.connectedToVM) return; // If the canvas is the same size as the screen, don't bother redrawing if (window.innerWidth >= this.actualScreenSize.width && this.canvas.width === this.actualScreenSize.width) return; if (this.actualScreenSize.width === this.canvasScale.width && this.actualScreenSize.height === this.canvasScale.height) { this.unscaledCtx.drawImage(this.canvas, 0, 0); } this.recalculateCanvasScale(this.actualScreenSize.width, this.actualScreenSize.height); this.canvas.width = this.canvasScale.width; this.canvas.height = this.canvasScale.height; this.ctx.drawImage(this.unscaledCanvas, 0, 0, this.actualScreenSize.width, this.actualScreenSize.height, 0, 0, this.canvas.width, this.canvas.height); } private recalculateCanvasScale(width: number, height: number) { this.actualScreenSize.width = width; this.actualScreenSize.height = height; // If the screen is bigger than the canvas, don't downscale if (window.innerWidth >= this.actualScreenSize.width) { this.canvasScale.width = this.actualScreenSize.width; this.canvasScale.height = this.actualScreenSize.height; } else { // If the canvas is bigger than the screen, downscale this.canvasScale.width = window.innerWidth; this.canvasScale.height = (window.innerWidth / this.actualScreenSize.width) * this.actualScreenSize.height; } } async WaitForOpen() { return new Promise((res) => { // TODO: should probably reject on close let unsub = this.onInternal('open', () => { unsub(); res(); }); }); } // Sends a message to the server send(...args: StringLike[]) { let guacElements = [...args].map((el) => { // This catches cases where the thing already is a string if (typeof el == 'string') return el as string; return el.toString(); }); this.socket.send(Guacutils.encode(...guacElements)); } // Get a list of all VMs list(): Promise { return new Promise((res, rej) => { let u = this.onInternal('list', (list: string[]) => { u(); let vms: VM[] = []; for (let i = 0; i < list.length; i += 3) { let 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) => { let u = this.onInternal('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; // call all unsubscribe callbacks explicitly for (let cb of this.unsubscribeCallbacks) { cb(); } this.unsubscribeCallbacks = []; 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) { let x = Math.round((_x / this.canvas.width) * this.actualScreenSize.width); let y = Math.round((_y / this.canvas.height) * this.actualScreenSize.height); this.send('mouse', x, y, mask); } // Send key key(keysym: number, down: boolean) { this.send('key', keysym, 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', AdminOpcode.Login, password); } /* Admin commands */ // Restore restore() { if (!this.node) return; this.send('admin', AdminOpcode.Restore, this.node!); } // Reboot reboot() { if (!this.node) return; this.send('admin', AdminOpcode.Reboot, this.node!); } // Clear turn queue clearQueue() { if (!this.node) return; this.send('admin', AdminOpcode.ClearTurns, this.node!); } // Bypass turn bypassTurn() { this.send('admin', AdminOpcode.BypassTurn); } // End turn endTurn(user: string) { this.send('admin', AdminOpcode.EndTurn, user); } // Ban ban(user: string) { this.send('admin', AdminOpcode.BanUser, user); } // Kick kick(user: string) { this.send('admin', AdminOpcode.KickUser, user); } // Rename user renameUser(oldname: string, newname: string) { this.send('admin', AdminOpcode.RenameUser, oldname, newname); } // Mute user mute(user: string, state: MuteState) { this.send('admin', AdminOpcode.MuteUser, user, state); } // Grab IP getip(user: string) { if (this.users.find((u) => u.username === user) === undefined) return false; return new Promise((res) => { let unsubscribe = this.onInternal('ip', (username: string, ip: string) => { if (username !== user) return; unsubscribe(); res(ip); }); this.send('admin', AdminOpcode.GetIP, user); }); } // QEMU Monitor qemuMonitor(cmd: string) { return new Promise((res) => { let unsubscribe = this.onInternal('qemu', (output) => { unsubscribe(); res(output); }); this.send('admin', AdminOpcode.MonitorCommand, this.node!, cmd); }); } // XSS xss(msg: string) { this.send('admin', AdminOpcode.ChatXSS, msg); } // Force vote forceVote(result: boolean) { this.send('admin', AdminOpcode.ForceVote, result ? '1' : '0'); } // Toggle turns turns(enabled: boolean) { this.send('admin', AdminOpcode.ToggleTurns, enabled ? '1' : '0'); } // Indefinite turn indefiniteTurn() { this.send('admin', AdminOpcode.IndefiniteTurn); } // Hide screen hideScreen(hidden: boolean) { this.send('admin', AdminOpcode.HideScreen, hidden ? '1' : '0'); } // Login to account loginAccount(token: string) { this.send('login', token); } usesAccountAuth() { return this.auth; } getNode() { return this.node; } private onInternal(event: E, callback: CollabVMClientPrivateEvents[E]): Unsubscribe { return this.internalEmitter.on(event, callback); } on(event: E, callback: CollabVMClientEvents[E]): Unsubscribe { let unsub = this.publicEmitter.on(event, callback); this.unsubscribeCallbacks.push(unsub); return unsub; } }