From 6c1205490fb1c709e91dde1dc874a22791afed3a Mon Sep 17 00:00:00 2001 From: Elijah R Date: Thu, 1 Feb 2024 21:45:47 -0500 Subject: [PATCH] Implement opening the VM, as well as viewing the screen, chat, and userlist. It's pretty much fully functional as a view-only client, next up is interaction --- src/css/style.css | 10 +- src/html/index.html | 6 +- src/ts/main.ts | 187 ++++++++++++++++++++++++++++-- src/ts/protocol/CollabVMClient.ts | 108 ++++++++++++++++- src/ts/protocol/Permissions.ts | 31 +++++ src/ts/protocol/User.ts | 11 ++ 6 files changed, 336 insertions(+), 17 deletions(-) create mode 100644 src/ts/protocol/Permissions.ts create mode 100644 src/ts/protocol/User.ts diff --git a/src/css/style.css b/src/css/style.css index 954c420..6eb1b37 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -12,7 +12,7 @@ display: block; padding: 4px; }*/ -#display, #btns { +#vmDisplay, #btns { margin-left: auto; margin-right: auto; text-align: center; @@ -85,4 +85,12 @@ #forceVotePanel { display: none; +} + +.user-admin { + color: #FF0000 !important; +} + +.user-moderator { + color: #00FF00 !important; } \ No newline at end of file diff --git a/src/html/index.html b/src/html/index.html index 3c309b3..a4539ec 100644 --- a/src/html/index.html +++ b/src/html/index.html @@ -117,9 +117,7 @@
-
- -
+

-
+
diff --git a/src/ts/main.ts b/src/ts/main.ts index 59f21dc..57ffaf2 100644 --- a/src/ts/main.ts +++ b/src/ts/main.ts @@ -1,13 +1,28 @@ import CollabVMClient from "./protocol/CollabVMClient.js"; import VM from "./protocol/VM.js"; import { Config } from "../../Config.js"; +import { Rank } from "./protocol/Permissions.js"; +import { User } from "./protocol/User.js"; // Elements +const w = window as any; const elements = { vmlist: document.getElementById('vmlist') as HTMLDivElement, + vmview: document.getElementById('vmview') as HTMLDivElement, + vmDisplay: document.getElementById('vmDisplay') as HTMLDivElement, + homeBtn: document.getElementById('homeBtn') as HTMLAnchorElement, + chatList: document.getElementById('chatList') as HTMLTableSectionElement, + chatListDiv: document.getElementById('chatListDiv') as HTMLDivElement, + userlist: document.getElementById('userlist') as HTMLTableSectionElement, + onlineusercount: document.getElementById("onlineusercount") as HTMLSpanElement, + username: document.getElementById("username") as HTMLSpanElement, } // Listed VMs const vms : VM[] = []; +const cards : HTMLDivElement[] = []; + +// Active VM +var VM : CollabVMClient | null = null; function multicollab(url : string) { return new Promise(async (res, rej) => { @@ -36,31 +51,181 @@ function multicollab(url : string) { card.appendChild(vm.thumbnail); card.appendChild(cardBody); div.appendChild(card); - elements.vmlist.children[0].appendChild(div); - reloadVMList(); + cards.push(div); + sortVMList(); } res(); }); } function openVM(vm : VM) { - + return new Promise(async (res, rej) => { + // If there's an active VM it must be closed before opening another + if (VM !== null) return; + // Set hash + location.hash = vm.id; + // Create the client + VM = new CollabVMClient(vm.url); + // Register event listeners + VM!.on('chat', (username, message) => chatMessage(username, message)); + VM!.on('adduser', (user) => addUser(user)); + VM!.on('remuser', (user) => remUser(user)); + VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)); + VM!.on('renamestatus', (status) => { + switch (status) { + case 'taken': alert("That username is already taken"); break; + case 'invalid': alert("Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters."); break; + case 'blacklisted': alert("That username has been blacklisted."); break; + } + }); + // Wait for the client to open + await new Promise(res => VM!.on('open', () => res())); + // Connect to node + chatMessage("", vm.id); + var connected = await VM.connect(vm.id); + if (!connected) { + VM.close(); + VM = null; + rej("Failed to connect to node"); + } + // Set the title + document.title = vm.id + " - CollabVM"; + // Append canvas + elements.vmDisplay.appendChild(VM!.canvas); + // Switch to the VM view + elements.vmlist.style.display = "none"; + elements.vmview.style.display = "block"; + }); } -function reloadVMList() { - var cards = Array.prototype.slice.call(elements.vmlist.children[0].children); +function closeVM() { + if (VM === null) return; + // Close the VM + VM.close(); + VM = null; + // Remove the canvas + elements.vmDisplay.innerHTML = ""; + // Switch to the VM list + elements.vmlist.style.display = "block"; + elements.vmview.style.display = "none"; + // Clear users + elements.userlist.innerHTML = ""; +} + +function loadList() { + return new Promise(async res => { + var p = []; + for (var url of Config.ServerAddresses) { + p.push(multicollab(url)); + } + await Promise.all(p); + var v = vms.find(v => v.id === window.location.hash.slice(1)); + if (v !== undefined) openVM(v); + res(); + }); +} + +function sortVMList() { cards.sort(function(a, b) { - return a.id > b.id ? 1 : -1; + return a.children[0].getAttribute("data-cvm-node")! > b.id ? 1 : -1; }); elements.vmlist.children[0].innerHTML = ""; cards.forEach((c) => elements.vmlist.children[0].appendChild(c)); } +function chatMessage(username : string, message : string) { + var tr = document.createElement('tr'); + var td = document.createElement('td'); + // System message + if (username === "") td.innerHTML = message; + else { + var user = VM!.getUsers().find(u => u.username === username); + var rank; + if (user !== undefined) rank = user.rank; + else rank = Rank.Unregistered; + var userclass; + var msgclass; + switch (rank) { + case Rank.Unregistered: + userclass = "user-unregistered"; + msgclass = "chat-unregistered"; + break; + case Rank.Admin: + userclass = "user-admin"; + msgclass = "chat-admin"; + break; + case Rank.Moderator: + userclass = "user-mod"; + msgclass = "chat-moderator"; + break; + } + tr.classList.add(msgclass); + td.innerHTML = `${username}▸ ${message}`; + // hacky way to allow scripts + Array.prototype.slice.call(td.children).forEach((curr) => { + if (curr.nodeName === "SCRIPT") { + eval(curr.text) + } + }); + tr.appendChild(td); + elements.chatList.appendChild(tr); + elements.chatListDiv.scrollTop = elements.chatListDiv.scrollHeight; + } +} + +function addUser(user : User) { + var olduser = Array.prototype.slice.call(elements.userlist.children).find((u : HTMLTableRowElement) => u.children[0].innerHTML === user.username); + if (olduser !== undefined) elements.userlist.removeChild(olduser); + var tr = document.createElement('tr'); + var td = document.createElement('td'); + td.innerHTML = user.username; + switch (user.rank) { + case Rank.Admin: + td.classList.add("user-admin"); + break; + case Rank.Moderator: + td.classList.add("user-moderator"); + break; + case Rank.Unregistered: + td.classList.add("user-unregistered"); + break; + } + tr.appendChild(td); + elements.userlist.appendChild(tr); + elements.onlineusercount.innerHTML = VM!.getUsers().length.toString(); +} + +function remUser(user : User) { + var olduser = Array.prototype.slice.call(elements.userlist.children).find((u : HTMLTableRowElement) => u.children[0].innerHTML === user.username); + if (olduser !== undefined) elements.userlist.removeChild(olduser); + elements.onlineusercount.innerHTML = VM!.getUsers().length.toString(); +} + +function userRenamed(oldname : string, newname : string, selfrename : boolean) { + var user = Array.prototype.slice.call(elements.userlist.children).find((u : HTMLTableRowElement) => u.children[0].innerHTML === oldname); + if (user) { + user.children[0].innerHTML = newname; + } + if (selfrename) { + w.username = newname; + elements.username.innerText = newname; + localStorage.setItem("username", newname); + } +} + +// Bind list buttons +elements.homeBtn.addEventListener('click', () => closeVM()); + // Public API -var w = window as any; +w.collabvm = { + openVM: openVM, + closeVM: closeVM, + loadList: loadList, + multicollab: multicollab +} +// Multicollab will stay in the global scope for backwards compatibility w.multicollab = multicollab; -w.openVM = openVM; +// Same goes for GetAdmin +// w.GetAdmin = () => VM.admin; // Load all VMs -for (var url of Config.ServerAddresses) { - multicollab(url); -} \ No newline at end of file +loadList(); diff --git a/src/ts/protocol/CollabVMClient.ts b/src/ts/protocol/CollabVMClient.ts index a683cab..195b930 100644 --- a/src/ts/protocol/CollabVMClient.ts +++ b/src/ts/protocol/CollabVMClient.ts @@ -1,13 +1,18 @@ import {createNanoEvents } from "nanoevents"; import * as Guacutils from './Guacutils.js'; import VM from "./VM.js"; +import { User } from "./User.js"; +import { Rank } from "./Permissions.js"; export default class CollabVMClient { // Fields private socket : WebSocket; - private canvas : HTMLCanvasElement; + canvas : HTMLCanvasElement; private ctx : CanvasRenderingContext2D; private url : string; + private connectedToVM : boolean = false; + private users : User[] = []; + private username : string | null = null; // events that are used internally and not exposed private emitter; // public events @@ -56,6 +61,82 @@ export default class CollabVMClient { // pass msgarr to the emitter for processing by list() console.log("got 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 = 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; } } } @@ -88,5 +169,30 @@ export default class CollabVMClient { }); } + // 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); + }) + } + + // Close the connection + close() { + this.connectedToVM = false; + this.socket.close(); + } + + // Get users + getUsers() : User[] { + // Return a copy of the array + return this.users.slice(); + } + on = (event : string | number, cb: (...args: any) => void) => this.publicEmitter.on(event, cb); } \ No newline at end of file diff --git a/src/ts/protocol/Permissions.ts b/src/ts/protocol/Permissions.ts new file mode 100644 index 0000000..03fb91a --- /dev/null +++ b/src/ts/protocol/Permissions.ts @@ -0,0 +1,31 @@ +export class Permissions { + restore : boolean = false; + reboot : boolean = false; + ban : boolean = false; + forcevote : boolean = false; + mute : boolean = false; + kick : boolean = false; + bypassturn : boolean = false; + rename : boolean = false; + grabip : boolean = false; + xss : boolean = false; + + constructor(mask : number) { + this.restore = (mask & 1) !== 0; + this.reboot = (mask & 2) !== 0; + this.ban = (mask & 4) !== 0; + this.forcevote = (mask & 8) !== 0; + this.mute = (mask & 16) !== 0; + this.kick = (mask & 32) !== 0; + this.bypassturn = (mask & 64) !== 0; + this.rename = (mask & 128) !== 0; + this.grabip = (mask & 256) !== 0; + this.xss = (mask & 512) !== 0; + } +} + +export enum Rank { + Unregistered = 0, + Admin = 2, + Moderator = 3, +} \ No newline at end of file diff --git a/src/ts/protocol/User.ts b/src/ts/protocol/User.ts new file mode 100644 index 0000000..2faaabc --- /dev/null +++ b/src/ts/protocol/User.ts @@ -0,0 +1,11 @@ +import { Rank } from "./Permissions.js"; + +export class User { + username : string; + rank : Rank + + constructor(username : string, rank : Rank = Rank.Unregistered) { + this.username = username; + this.rank = rank; + } +} \ No newline at end of file