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 @@
-
-
-
+
Do you want to reset the vm?
@@ -157,7 +155,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