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

This commit is contained in:
Elijah R 2024-02-01 21:45:47 -05:00
parent e8d1eb4b87
commit 6c1205490f
6 changed files with 336 additions and 17 deletions

View File

@ -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;
}

View File

@ -117,9 +117,7 @@
<div class="row"></div>
</div>
<div class="container-fluid" id="vmview">
<div id="vmDisplay">
</div>
<div id="vmDisplay"></div>
<p id="turnstatus" class="text-light"></p>
<div id="voteResetPanel" class="bg-dark text-light" style="display:none;">
Do you want to reset the vm?<br/>
@ -157,7 +155,7 @@
</div>
</div>
<div class="col-md-8">
<div class="table-responsive chat-table">
<div class="table-responsive chat-table" id="chatListDiv">
<table class="table table-hover table-dark table-borderless">
<tbody id="chatList">

View File

@ -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<void>(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<void>(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<void>(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<void>(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 = `<b class="${userclass}">${username}▸</b> ${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);
}
loadList();

View File

@ -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<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);
})
}
// 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);
}

View File

@ -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,
}

11
src/ts/protocol/User.ts Normal file
View File

@ -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;
}
}