Compare commits
30 Commits
1f49ef9f67
...
master
Author | SHA1 | Date | |
---|---|---|---|
f656079c7e
|
|||
0c0dafbecf
|
|||
2cce1a3dbf
|
|||
7a0a2dc3e5
|
|||
494e297224
|
|||
01a7bed1c9
|
|||
e8fe008e99
|
|||
7eb6d6a75f
|
|||
753ecec0b6
|
|||
c90de1a895
|
|||
6f76004dc0
|
|||
b480d35cb1
|
|||
5fdf49555f
|
|||
426e120e21
|
|||
0217ea90e2
|
|||
5b2deaa346
|
|||
43f26e8ea9
|
|||
787b198ffc
|
|||
826f566ad2
|
|||
2482ef02bd
|
|||
37c9431c29
|
|||
0d7cd28ca9
|
|||
968ede6f58
|
|||
b8b2ec2ebc
|
|||
2b2a9a6758
|
|||
9987086bf0
|
|||
eea681d435
|
|||
0601f52661
|
|||
eacddc0667
|
|||
998e93968f
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
.idea/
|
||||
dest/
|
||||
/*.sh
|
||||
|
21
Makefile
Normal file
21
Makefile
Normal file
@@ -0,0 +1,21 @@
|
||||
.DEFAULT_GOAL := build-www
|
||||
|
||||
build-www:
|
||||
mkdir -p dest/
|
||||
rm -rf dest/www
|
||||
cp -pr www dest/www
|
||||
|
||||
tsc
|
||||
|
||||
sed -i 's:"\(/res/[^"]*\|/favicon.ico\|/app.webmanifest\)":"\1?v=$(shell date -u +%Y%m%d-%H%M%S)":g' dest/www/index.html
|
||||
perl -i -pE \
|
||||
"s/^(import .*)\"(.*?)(\.js)?(\?.*?)?\"/(\$$1).(\"\\\".\/\$$2.js?v=$(shell date -u +%Y%m%d-%H%M%S)\\\"\")/ge" \
|
||||
dest/www/res/scripts/*.js
|
||||
|
||||
tools/minify-css.sh
|
||||
sed -i 's|/res/styles/styles.css|/res/styles/min.css|g' dest/www/index.html
|
||||
|
||||
#convert -background none dest/www/res/svgs/tucal.svg -alpha set -define icon:auto-resize=256,128,64,32,24,16 dest/www/favicon.ico
|
||||
|
||||
clean:
|
||||
rm -rf dest/
|
338
src/locutus.ts
Normal file
338
src/locutus.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
"use strict";
|
||||
|
||||
import * as USIMP from "usimp";
|
||||
|
||||
export class App {
|
||||
account: USIMP.Account | null;
|
||||
defaultLocation: string;
|
||||
main: HTMLElement;
|
||||
windows: HTMLElement;
|
||||
session: USIMP.Session | null;
|
||||
|
||||
constructor() {
|
||||
this.defaultLocation = '/welcome';
|
||||
this.account = null;
|
||||
this.session = null;
|
||||
|
||||
const main = document.getElementsByTagName("main")[0];
|
||||
if (!main) throw new Error("Element <main> not found");
|
||||
this.main = main;
|
||||
|
||||
const windows = document.getElementById("windows");
|
||||
if (!windows) throw new Error("Element #windows not found");
|
||||
this.windows = windows;
|
||||
|
||||
window.addEventListener("hashchange", event => {
|
||||
this.handleUrl(event.newURL);
|
||||
});
|
||||
|
||||
const domainName = window.localStorage['domainName'];
|
||||
const sessionToken = window.localStorage['sessionToken'];
|
||||
if (domainName && sessionToken) {
|
||||
USIMP.Domain.fromName(domainName).then((domain) => {
|
||||
const session = new USIMP.Session(domain);
|
||||
session.chooseDomainServer().then(() => {
|
||||
session.token = sessionToken;
|
||||
session.ping().then(() => {
|
||||
this.session = session;
|
||||
this.defaultLocation = '/';
|
||||
this.handleUrl(document.URL);
|
||||
})
|
||||
});
|
||||
}, (error) => {
|
||||
if (error instanceof DOMException) {
|
||||
throw new Error("Connection timed out");
|
||||
} else {
|
||||
throw new Error("Invalid USIMP domain");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.handleUrl(document.URL);
|
||||
}
|
||||
}
|
||||
|
||||
quit() {
|
||||
if (this.session) this.session.close().then();
|
||||
}
|
||||
|
||||
sleep() {
|
||||
if (this.session) this.session.sleep().then();
|
||||
}
|
||||
|
||||
async wakeup() {
|
||||
if (this.session) await this.session.wakeup();
|
||||
}
|
||||
|
||||
setHash(hash: string) {
|
||||
const url = new URL(document.URL);
|
||||
url.hash = hash;
|
||||
location.href = url.toString();
|
||||
}
|
||||
|
||||
handleUrl(url: string) {
|
||||
this.handleHash(new URL(url).hash);
|
||||
}
|
||||
|
||||
handleHash(hash: string) {
|
||||
if (hash[0] === '#') hash = hash.substring(1);
|
||||
|
||||
const defaultCase = () => {
|
||||
history.replaceState(null, document.title, `#${this.defaultLocation}`);
|
||||
this.handleHash(this.defaultLocation);
|
||||
}
|
||||
|
||||
switch (hash) {
|
||||
case "/":
|
||||
if (!this.session) {
|
||||
return defaultCase();
|
||||
}
|
||||
this.hideWindows();
|
||||
this.showMain();
|
||||
this.removeAllWindows();
|
||||
this.setupMain();
|
||||
break;
|
||||
case "/welcome":
|
||||
this.hideMain();
|
||||
this.showWindows();
|
||||
this.removeAllWindows();
|
||||
this.addWelcomeWindow();
|
||||
break;
|
||||
case "/login":
|
||||
if (this.session) {
|
||||
return defaultCase();
|
||||
}
|
||||
this.hideMain();
|
||||
this.showWindows();
|
||||
this.removeAllWindows();
|
||||
this.addLoginWindow();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Invalid url hash #${hash}`);
|
||||
return defaultCase();
|
||||
}
|
||||
}
|
||||
|
||||
async login(accountName: string, domainName: string, password: string) {
|
||||
let domain;
|
||||
try {
|
||||
domain = await USIMP.Domain.fromName(domainName);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException) {
|
||||
throw new Error("Connection timed out");
|
||||
} else {
|
||||
throw new Error("Invalid USIMP domain");
|
||||
}
|
||||
}
|
||||
|
||||
const session = new USIMP.Session(domain);
|
||||
const rtt = await session.chooseDomainServer();
|
||||
console.log(rtt);
|
||||
|
||||
const response = await session.authenticate(accountName, password);
|
||||
if (response.status === "success") {
|
||||
this.session = session;
|
||||
this.defaultLocation = "/";
|
||||
return true;
|
||||
} else {
|
||||
console.error(response.error);
|
||||
throw new Error(response.error.message || response.error.code);
|
||||
}
|
||||
}
|
||||
|
||||
hideMain() {
|
||||
this.main.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
showMain() {
|
||||
this.main.style.visibility = "visible";
|
||||
}
|
||||
|
||||
hideWindows() {
|
||||
this.windows.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
showWindows() {
|
||||
this.windows.style.visibility = "visible";
|
||||
}
|
||||
|
||||
removeAllWindows() {
|
||||
while (this.windows.lastChild) this.windows.removeChild(this.windows.lastChild);
|
||||
}
|
||||
|
||||
addWelcomeWindow() {
|
||||
const win = document.createElement("div");
|
||||
win.classList.add("window-welcome");
|
||||
|
||||
win.innerHTML = `
|
||||
<h1>Welcome to Locutus!</h1>
|
||||
<a href="#/login" class="button">Login</a>`;
|
||||
|
||||
this.windows.appendChild(win);
|
||||
}
|
||||
|
||||
addMessage(id: string, message: string, delivered: boolean = true) {
|
||||
const test = document.getElementById(`msg-${id}`);
|
||||
if (test) return;
|
||||
|
||||
const msg = document.createElement("div");
|
||||
msg.id = `msg-${id}`;
|
||||
msg.classList.add("message");
|
||||
if (delivered) {
|
||||
msg.classList.add("delivered");
|
||||
} else {
|
||||
msg.classList.add("pending");
|
||||
}
|
||||
|
||||
msg.innerText = message;
|
||||
|
||||
const chat = this.main.getElementsByClassName("chat-history")[0];
|
||||
if (!chat) throw new Error("Element .chat-history not found");
|
||||
|
||||
chat.appendChild(msg);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
confirmMessage(id: string) {
|
||||
const msg = document.getElementById(`msg-${id}`);
|
||||
if (!msg) return;
|
||||
|
||||
msg.classList.remove("pending");
|
||||
msg.classList.add("delivered");
|
||||
}
|
||||
|
||||
addLoginWindow() {
|
||||
const win = document.createElement("div");
|
||||
win.classList.add("window-login");
|
||||
|
||||
win.innerHTML = `
|
||||
<h1>Login to USIMP Account</h1>
|
||||
<form>
|
||||
<input name="account" placeholder="Account name" type="text" required/>
|
||||
<input name="domain" placeholder="Domain" type="text" pattern="([a-zA-Z0-9_-]+\\.)+[a-zA-Z]{2,}" required/>
|
||||
<input name="password" placeholder="Password" type="password" required/>
|
||||
<fieldset>
|
||||
<label><input name="saveAccountName" type="checkbox"/> Save Account Name</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<label><input name="keepSession" type="checkbox"/> Keep me signed in</label>
|
||||
</fieldset>
|
||||
<button type="submit">Login</button>
|
||||
</form>`;
|
||||
|
||||
const form = win.getElementsByTagName("form")[0];
|
||||
if (!form) throw new Error("Element <form> not found");
|
||||
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
|
||||
for (const e of form.getElementsByTagName("button")) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("input")) {
|
||||
e.disabled = true;
|
||||
e.removeAttribute("invalid");
|
||||
}
|
||||
|
||||
try {
|
||||
const accountName: string = form['account'].value;
|
||||
const domainName: string = form['domain'].value;
|
||||
const keepSession: boolean = form['keepSession'].checked;
|
||||
const saveAccountName: boolean = form['saveAccountName'].checked;
|
||||
try {
|
||||
if (await this.login(accountName, domainName, form['password'].value)) {
|
||||
window.localStorage.clear();
|
||||
if (keepSession || saveAccountName)
|
||||
window.localStorage['domainName'] = domainName;
|
||||
if (keepSession)
|
||||
window.localStorage['sessionToken'] = this.session?.token;
|
||||
if (saveAccountName)
|
||||
window.localStorage['accountName'] = accountName;
|
||||
this.setHash("/");
|
||||
}
|
||||
} finally {
|
||||
for (const d of form.getElementsByTagName("div")) form.removeChild(d);
|
||||
for (const e of form.getElementsByTagName("input")) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("button")) e.disabled = false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.toString() === "Error: unable to authenticate") {
|
||||
const account = document.getElementsByName("account")[0];
|
||||
if (account) account.setAttribute("invalid", "invalid");
|
||||
} else {
|
||||
const domain = document.getElementsByName("domain")[0];
|
||||
if (domain) domain.setAttribute("invalid", "invalid");
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("error");
|
||||
div.innerText = error.toString();
|
||||
form.appendChild(div);
|
||||
}
|
||||
});
|
||||
|
||||
this.windows.appendChild(win);
|
||||
|
||||
const domain = document.getElementsByName("domain")[0];
|
||||
if (!domain) throw new Error("Element name=domain not found");
|
||||
domain.addEventListener("input", () => {
|
||||
domain.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
const account = document.getElementsByName("account")[0];
|
||||
if (!account) throw new Error("Element name=account not found");
|
||||
account.addEventListener("input", () => {
|
||||
account.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
const password = document.getElementsByName("password")[0];
|
||||
if (!password) throw new Error("Element name=password not found");
|
||||
|
||||
const accountName = window.localStorage['accountName'];
|
||||
const domainName = window.localStorage['domainName'];
|
||||
if (accountName && domainName) {
|
||||
form['account'].value = accountName;
|
||||
form['domain'].value = domainName;
|
||||
form['saveAccountName'].checked = true;
|
||||
password.focus();
|
||||
} else {
|
||||
account.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setupMain() {
|
||||
if (!this.session) throw new Error("Invalid state");
|
||||
|
||||
this.main.innerHTML = `
|
||||
<div class="chat">
|
||||
<div class="chat-history"></div>
|
||||
<div class="chat-input-wrapper">
|
||||
<input name="message" type="text" autocomplete="off"/>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const input = document.getElementsByTagName("input")[0];
|
||||
if (!input) throw new Error("Element <input> not found");
|
||||
|
||||
input.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" && input.value.length > 0) {
|
||||
if (!this.session) throw new Error("No session found");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const val = input.value;
|
||||
this.addMessage(id, input.value, false);
|
||||
|
||||
this.session.newEvent(
|
||||
"24595934-4540-4333-ac2b-78796ac3f25f",
|
||||
{message: val},
|
||||
id
|
||||
).then(() => {
|
||||
this.confirmMessage(id);
|
||||
});
|
||||
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
this.session.subscribe(event => {
|
||||
this.addMessage(event.id, event.data.message);
|
||||
}).then();
|
||||
}
|
||||
}
|
44
src/main.ts
Normal file
44
src/main.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
import * as Locutus from "locutus";
|
||||
|
||||
function resize() {
|
||||
document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// Remove <noscript> tags
|
||||
for (const noscript of document.getElementsByTagName("noscript")) {
|
||||
noscript.remove();
|
||||
}
|
||||
|
||||
const locutus = new Locutus.App();
|
||||
|
||||
if (isMobilePlatform()) {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
locutus.sleep();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
locutus.wakeup().then();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
locutus.quit();
|
||||
});
|
||||
});
|
||||
|
||||
function isMobilePlatform(): boolean {
|
||||
if (window.matchMedia("(any-pointer:coarse)").matches) return true;
|
||||
const ua = navigator.userAgent;
|
||||
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
|
||||
return true;
|
||||
} else if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
651
src/usimp.ts
Normal file
651
src/usimp.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
"use strict";
|
||||
|
||||
const DNS_RE = /^([a-z0-9_-]+\.)+[a-z]{2,}$/i;
|
||||
const UUID_RE = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const WEBSOCKET_KEEP_ALIVE = 5 * 60; // [sec]
|
||||
|
||||
type DomainJson = {
|
||||
name: string,
|
||||
id: string,
|
||||
}
|
||||
|
||||
function isDomainJson(json: any): json is DomainJson {
|
||||
return (
|
||||
json !== null && typeof json === 'object' && json.constructor === Object &&
|
||||
typeof json.name === 'string' && typeof json.id === 'string' &&
|
||||
DNS_RE.test(json.name) && UUID_RE.test(json.id)
|
||||
);
|
||||
}
|
||||
|
||||
type DomainServerJson = {
|
||||
host: string,
|
||||
id: string,
|
||||
priority: number,
|
||||
weight: number,
|
||||
protocols: {
|
||||
https?: number | null,
|
||||
wss?: number | null,
|
||||
http?: number | null,
|
||||
ws?: number | null,
|
||||
udp?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
function isDomainServerJson(json: any): json is DomainServerJson {
|
||||
if (
|
||||
json === null || typeof json !== 'object' || json.constructor !== Object ||
|
||||
typeof json.host !== 'string' || typeof json.id !== 'string' || typeof json.priority !== 'number' ||
|
||||
typeof json.weight !== 'number' ||
|
||||
json.protocols === null || typeof json.protocols !== 'object' || json.protocols.constructor !== Object ||
|
||||
!DNS_RE.test(json.host) || !UUID_RE.test(json.id) || json.priority < 0 || json.weight < 0
|
||||
) return false;
|
||||
|
||||
|
||||
return (
|
||||
(json.protocols.https === undefined || json.protocols.https === null || (
|
||||
typeof json.protocols.https === 'number' && json.protocols.https > 0 && json.protocols.https <= 65535
|
||||
)) && (json.protocols.wss === undefined || json.protocols.wss === null || (
|
||||
typeof json.protocols.wss === 'number' && json.protocols.wss > 0 && json.protocols.wss <= 65535
|
||||
)) && (json.protocols.http === undefined || json.protocols.http === null || (
|
||||
typeof json.protocols.http === 'number' && json.protocols.http > 0 && json.protocols.http <= 65535
|
||||
)) && (json.protocols.ws === undefined || json.protocols.ws === null || (
|
||||
typeof json.protocols.ws === 'number' && json.protocols.ws > 0 && json.protocols.ws <= 65535
|
||||
)) && (json.protocols.udp === undefined || json.protocols.udp === null || (
|
||||
typeof json.protocols.udp === 'number' && json.protocols.udp > 0 && json.protocols.udp <= 65535
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
type WellKnownJson = {
|
||||
domain: DomainJson,
|
||||
domain_servers: DomainServerJson[],
|
||||
}
|
||||
|
||||
function isWellKnownJson(json: any): json is WellKnownJson {
|
||||
if (
|
||||
json === null || typeof json !== 'object' || json.constructor !== Object ||
|
||||
!json.hasOwnProperty('domain') || !json.hasOwnProperty('domain_servers') ||
|
||||
json.domain_servers === null || typeof json.domain_servers !== 'object' ||
|
||||
json.domain_servers.constructor !== Array
|
||||
) return false;
|
||||
|
||||
return isDomainJson(json.domain) && json.domain_servers.every((item: unknown) => isDomainServerJson(item));
|
||||
}
|
||||
|
||||
type OutputEnvelopeJson<T> = {
|
||||
status: string,
|
||||
request_nr: number,
|
||||
data: T,
|
||||
action?: string | null,
|
||||
error?: {
|
||||
code: string,
|
||||
message: string | null,
|
||||
description: string | null,
|
||||
} | null,
|
||||
}
|
||||
|
||||
function isOutputEnvelopeJson<T>(json: any): json is OutputEnvelopeJson<T> {
|
||||
if (
|
||||
json === null || typeof json !== 'object' || json.constructor !== Object ||
|
||||
typeof json.status !== 'string' || typeof json.request_nr !== 'number' || json.request_nr < 0 ||
|
||||
(typeof json.action !== 'string' && json.action !== null && json.action === undefined) ||
|
||||
json.data === null || typeof json.data !== 'object' || json.data.constructor !== Object
|
||||
) return false;
|
||||
|
||||
if (json.error !== null && json.error !== undefined) {
|
||||
if (
|
||||
typeof json.error !== 'object' || json.error.constructor !== Object ||
|
||||
typeof json.error.code !== 'string' ||
|
||||
(typeof json.error.message !== 'string' && json.error.message !== null) ||
|
||||
(typeof json.error.description !== 'string' && json.error.description !== null)
|
||||
) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type EventPushEnvelopeJson = {
|
||||
events: EventJson[],
|
||||
}
|
||||
|
||||
function isEventPushEnvelopeJson(json: any): json is EventPushEnvelopeJson {
|
||||
if (
|
||||
json === null || typeof json !== 'object' || json.constructor !== Object ||
|
||||
json.events === null || typeof json.events !== 'object' || json.events.constructor !== Array
|
||||
) return false;
|
||||
|
||||
return json.events.every((item: unknown) => isEventJson(item));
|
||||
}
|
||||
|
||||
type EventJson = {
|
||||
id: string,
|
||||
data: {
|
||||
message: string,
|
||||
},
|
||||
}
|
||||
|
||||
function isEventJson(json: any): json is EventJson {
|
||||
return !(
|
||||
json === null || typeof json !== 'object' || json.constructor !== Object ||
|
||||
typeof json.id !== 'string' || !UUID_RE.test(json.id) ||
|
||||
json.data === null || typeof json.data !== 'object' || json.data.constructor !== Object ||
|
||||
typeof json.data.message !== 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class Domain {
|
||||
name: string;
|
||||
id: string;
|
||||
servers: DomainServer[];
|
||||
invalidServers: DomainServer[];
|
||||
|
||||
toString() {
|
||||
return `[${this.name}]`;
|
||||
}
|
||||
|
||||
constructor(json: DomainJson, domainServers: DomainServerJson[]) {
|
||||
this.name = json.name;
|
||||
this.id = json.id;
|
||||
this.servers = [];
|
||||
for (const domainServer of domainServers) {
|
||||
this.servers.push(new DomainServer(domainServer));
|
||||
}
|
||||
this.invalidServers = [];
|
||||
}
|
||||
|
||||
static async fromName(domainName: string): Promise<Domain> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
const response = await fetch(`https://${domainName}/.well-known/usimp.json`, {
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) {
|
||||
throw new Error("Invalid response");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (isWellKnownJson(data)) {
|
||||
return new Domain(data.domain, data.domain_servers);
|
||||
} else {
|
||||
throw Error("Invalid contents in usimp.json");
|
||||
}
|
||||
}
|
||||
|
||||
chooseDomainServer(): DomainServer {
|
||||
if (this.servers.length === 0) throw new Error("No domain servers specified");
|
||||
|
||||
const servers = this.servers.filter(srv => !this.invalidServers.map(srv => srv.id).includes(srv.id));
|
||||
if (servers.length === 0) throw new Error("No domain servers reachable");
|
||||
if (servers.length === 1 && servers[0]) return servers[0];
|
||||
|
||||
const priority = servers.reduce((min, srv) => Math.min(min, srv.priority), Infinity);
|
||||
const domainServers = servers.filter(srv => srv.priority === priority);
|
||||
if (domainServers.length === 1 && servers[0]) return servers[0];
|
||||
|
||||
const totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0);
|
||||
const w = Math.floor(Math.random() * totalWeight);
|
||||
|
||||
let accumulator = 0;
|
||||
for (const srv of domainServers) {
|
||||
accumulator += srv.weight;
|
||||
if (w < accumulator) return srv;
|
||||
}
|
||||
|
||||
throw new Error("Domain server selection did not work correctly");
|
||||
}
|
||||
}
|
||||
|
||||
export class DomainServer {
|
||||
host: string;
|
||||
id: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
protocols: {
|
||||
https: number | null,
|
||||
wss: number | null,
|
||||
http: number | null,
|
||||
ws: number | null,
|
||||
udp: number | null
|
||||
};
|
||||
|
||||
toString() {
|
||||
return `[${this.host}/${this.priority}/${this.weight}]`;
|
||||
}
|
||||
|
||||
constructor(json: DomainServerJson) {
|
||||
this.host = json.host;
|
||||
this.id = json.id;
|
||||
this.priority = json.priority;
|
||||
this.weight = json.weight;
|
||||
this.protocols = {
|
||||
https: json.protocols.https || null,
|
||||
wss: json.protocols.wss || null,
|
||||
http: json.protocols.http || null,
|
||||
ws: json.protocols.ws || null,
|
||||
udp: json.protocols.udp || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Account {
|
||||
|
||||
}
|
||||
|
||||
export class Member {
|
||||
|
||||
}
|
||||
|
||||
export class Room {
|
||||
|
||||
}
|
||||
|
||||
export class Event {
|
||||
id: string;
|
||||
data: {
|
||||
message: string;
|
||||
};
|
||||
|
||||
constructor(json: EventJson) {
|
||||
this.data = {
|
||||
message: json.data.message,
|
||||
};
|
||||
this.id = json.id;
|
||||
}
|
||||
}
|
||||
|
||||
type EventHandler = (event: Event) => void;
|
||||
|
||||
export class OutputEnvelope<T> {
|
||||
status: string;
|
||||
requestNr: number;
|
||||
data: T;
|
||||
action: string | null;
|
||||
error: {
|
||||
code: string,
|
||||
message: string | null,
|
||||
description: string | null,
|
||||
} | null;
|
||||
|
||||
constructor(json: OutputEnvelopeJson<T>) {
|
||||
this.status = json.status;
|
||||
this.requestNr = json.request_nr;
|
||||
this.data = json.data;
|
||||
this.action = json.action || null;
|
||||
if (json.error) {
|
||||
this.error = {
|
||||
code: json.error.code,
|
||||
message: json.error.message,
|
||||
description: json.error.description,
|
||||
};
|
||||
} else {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OutputEnvelopeHandler<T> = (envelope: OutputEnvelope<T>) => void;
|
||||
|
||||
export class Session {
|
||||
domain: Domain;
|
||||
server: DomainServer | null;
|
||||
token: string | null;
|
||||
|
||||
httpBaseUrl: string | null;
|
||||
websocket: WebSocket | null;
|
||||
websocketPingInterval: number | null;
|
||||
numRequests: number;
|
||||
requestNumDiscriminator: number;
|
||||
subscriptions: {
|
||||
callback: EventHandler,
|
||||
requestNr: number | undefined,
|
||||
abortController: AbortController | undefined,
|
||||
}[];
|
||||
subscriptionEndpoints: {
|
||||
callback: EventHandler,
|
||||
}[];
|
||||
|
||||
constructor(domain: Domain) {
|
||||
this.domain = domain;
|
||||
this.numRequests = 0;
|
||||
this.requestNumDiscriminator = Session.genRequestNumDiscriminator();
|
||||
this.server = null;
|
||||
this.token = null;
|
||||
this.httpBaseUrl = null;
|
||||
this.websocket = null;
|
||||
this.websocketPingInterval = null;
|
||||
this.subscriptions = [];
|
||||
this.subscriptionEndpoints = [];
|
||||
}
|
||||
|
||||
private static genRequestNumDiscriminator(): number {
|
||||
// JS uses 64-bit double precision floating point numbers.
|
||||
// These can accurately represent integers up to 53 bit.
|
||||
// In JS bitwise operators only operate on 32-bit integers.
|
||||
// Here a 48-bit integer number is generated.
|
||||
return Math.floor(Math.random() * 0x1000000) * 0x1000000;
|
||||
}
|
||||
|
||||
async close(keepEndpoints: boolean = false) {
|
||||
await this.unsubscribeAll(keepEndpoints);
|
||||
if (this.websocket && (this.websocket.readyState !== WebSocket.CLOSING && this.websocket.readyState !== WebSocket.CLOSED)) {
|
||||
this.closeWebsocket()
|
||||
this.subscriptions = [];
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setWebsocketNull() {
|
||||
if (this.websocketPingInterval) clearInterval(this.websocketPingInterval);
|
||||
this.websocket = null;
|
||||
this.websocketPingInterval = null;
|
||||
}
|
||||
|
||||
closeWebsocket() {
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
this.setWebsocketNull();
|
||||
}
|
||||
}
|
||||
|
||||
hasWebsocket(): boolean {
|
||||
return this.websocket !== null;
|
||||
}
|
||||
|
||||
async sleep() {
|
||||
await this.close(true);
|
||||
}
|
||||
|
||||
async wakeup() {
|
||||
await this.chooseDomainServer();
|
||||
await this.ping();
|
||||
for (const endpoint of this.subscriptionEndpoints) {
|
||||
await this._subscribe(endpoint.callback);
|
||||
}
|
||||
}
|
||||
|
||||
async chooseDomainServer(): Promise<{ http: number | null, ws: number | null } | undefined> {
|
||||
while (!this.server) {
|
||||
this.server = this.domain.chooseDomainServer();
|
||||
|
||||
const host = this.server.host;
|
||||
const protocols = this.server.protocols;
|
||||
|
||||
if ("wss" in protocols) {
|
||||
this.websocket = new WebSocket(`wss://${host}:${protocols.wss}/_usimp/websocket`, ["usimp"]);
|
||||
this.websocket.addEventListener("error", (error) => {
|
||||
console.error(error);
|
||||
this.setWebsocketNull();
|
||||
});
|
||||
this.websocketPingInterval = setInterval(() => {
|
||||
if (this.hasWebsocket()) this.send("ping", {})
|
||||
}, WEBSOCKET_KEEP_ALIVE * 1000);
|
||||
}
|
||||
|
||||
if ("https" in protocols) {
|
||||
this.httpBaseUrl = `https://${host}:${protocols.https}/_usimp`;
|
||||
try {
|
||||
return await this.ping();
|
||||
} catch {
|
||||
if (this.server !== null) {
|
||||
this.domain.invalidServers.push(this.server);
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`Domain server ${this.server} does not support 'https' transport protocol`);
|
||||
this.domain.invalidServers.push(this.server);
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
waitForWebSocket(timeout: number) {
|
||||
if (this.websocket === null) throw new Error("websocket not initialized");
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.websocket?.readyState === this.websocket?.OPEN) {
|
||||
resolve();
|
||||
} else {
|
||||
const handlerOpen = () => {
|
||||
clearTimeout(timer);
|
||||
this.websocket?.removeEventListener("open", handlerOpen);
|
||||
this.websocket?.removeEventListener("error", handlerError);
|
||||
resolve();
|
||||
};
|
||||
const handlerError = (error: any) => {
|
||||
clearTimeout(timer);
|
||||
this.websocket?.removeEventListener("open", handlerOpen);
|
||||
this.websocket?.removeEventListener("error", handlerError);
|
||||
reject(error);
|
||||
}
|
||||
const handlerTimeout = () => {
|
||||
this.websocket?.removeEventListener("open", handlerOpen);
|
||||
this.websocket?.removeEventListener("error", handlerError);
|
||||
reject(new Error("timeout"));
|
||||
}
|
||||
const timer = setTimeout(handlerTimeout, timeout);
|
||||
this.websocket?.addEventListener("open", handlerOpen);
|
||||
this.websocket?.addEventListener("error", handlerError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
waitForWebSocketResponse(req_nr: number, timeout: number) {
|
||||
if (this.websocket === null) throw new Error("websocket not initialized");
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const handlerMsg = (msg: any) => {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data['request_nr'] === req_nr) {
|
||||
clearTimeout(timer);
|
||||
this.websocket?.removeEventListener("message", handlerMsg);
|
||||
this.websocket?.removeEventListener("error", handlerError);
|
||||
resolve(data);
|
||||
}
|
||||
}
|
||||
const handlerError = (error: any) => {
|
||||
clearTimeout(timer);
|
||||
this.websocket?.removeEventListener("message", handlerMsg);
|
||||
this.websocket?.removeEventListener("error", handlerError);
|
||||
reject(error);
|
||||
}
|
||||
const handlerTimeout = () => {
|
||||
this.websocket?.removeEventListener("message", handlerMsg);
|
||||
this.websocket?.removeEventListener("error", handlerError);
|
||||
reject(new Error("timeout"));
|
||||
}
|
||||
const timer = setTimeout(handlerTimeout, timeout);
|
||||
this.websocket?.addEventListener("message", handlerMsg);
|
||||
this.websocket?.addEventListener("error", handlerError);
|
||||
});
|
||||
}
|
||||
|
||||
async send(
|
||||
endpoint: string,
|
||||
data: Object,
|
||||
timeout: number = 2000,
|
||||
forceHttp: boolean = false,
|
||||
abortController: AbortController | undefined = undefined,
|
||||
cb: OutputEnvelopeHandler<unknown> | undefined = undefined,
|
||||
) {
|
||||
this.numRequests++;
|
||||
while (true) {
|
||||
try {
|
||||
if (!forceHttp && this.websocket !== null) {
|
||||
const req_nr = this.numRequests + this.requestNumDiscriminator;
|
||||
const startTime = performance.now();
|
||||
|
||||
await this.waitForWebSocket(timeout);
|
||||
|
||||
let response;
|
||||
if (cb === undefined) {
|
||||
response = this.waitForWebSocketResponse(req_nr, timeout);
|
||||
} else {
|
||||
this.websocket.addEventListener("message", (msg) => {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data !== null && data.constructor === Object && data['request_nr'] === req_nr) {
|
||||
if (isOutputEnvelopeJson<unknown>(data)) {
|
||||
cb(new OutputEnvelope<unknown>(data));
|
||||
} else {
|
||||
throw new Error("Invalid server response");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.websocket.send(JSON.stringify({
|
||||
'request_nr': req_nr,
|
||||
'endpoint': endpoint,
|
||||
'to_domain': this.domain.id,
|
||||
'token': this.token,
|
||||
'data': data
|
||||
}));
|
||||
|
||||
if (cb === undefined) {
|
||||
const responseData = await response;
|
||||
const endTime = performance.now();
|
||||
responseData.duration = endTime - startTime;
|
||||
return responseData;
|
||||
} else {
|
||||
return await this.waitForWebSocketResponse(req_nr, timeout);
|
||||
}
|
||||
} else if (this.httpBaseUrl) {
|
||||
const controller = abortController || new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
let headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'To-Domain': this.domain.id,
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `usimp ${this.token}`;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const response = await fetch(`${this.httpBaseUrl}/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const responseData = await response.json();
|
||||
|
||||
const endTime = performance.now();
|
||||
responseData.duration = endTime - startTime;
|
||||
return responseData;
|
||||
} else {
|
||||
throw new Error("No domain server chosen");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (this.hasWebsocket()) this.closeWebsocket();
|
||||
|
||||
this.httpBaseUrl = null;
|
||||
if (this.server !== null) {
|
||||
this.domain.invalidServers.push(this.server);
|
||||
this.server = null;
|
||||
}
|
||||
await this.chooseDomainServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ping() {
|
||||
let result = {"http": null, "ws": null};
|
||||
const resHttp = await this.send("ping", {}, undefined, true);
|
||||
result.http = resHttp.duration;
|
||||
|
||||
if (this.hasWebsocket()) {
|
||||
const resWs = await this.send("ping", {});
|
||||
result.ws = resWs.duration;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async authenticate(accountName: string, password: string) {
|
||||
const response = await this.send("authenticate", {
|
||||
type: "password",
|
||||
name: accountName,
|
||||
password: password,
|
||||
});
|
||||
if (response.status === "success") {
|
||||
this.token = response.data.token;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async newEvent(roomId: string, data: Object, id: string | undefined = undefined) {
|
||||
return this.send("new_event", {
|
||||
room_id: roomId,
|
||||
events: [{
|
||||
data: data,
|
||||
id: id,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
async subscribe(cb: EventHandler) {
|
||||
this.subscriptionEndpoints.push({callback: cb});
|
||||
await this._subscribe(cb);
|
||||
}
|
||||
|
||||
private async _subscribe(cb: EventHandler) {
|
||||
if (this.hasWebsocket()) {
|
||||
const subscription = await this.send('subscribe', {}, 60_000, false, undefined, (res) => {
|
||||
if (res.action === 'push') {
|
||||
if (isEventPushEnvelopeJson(res.data)) {
|
||||
for (const event of res.data.events) {
|
||||
cb(new Event(event));
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid server response");
|
||||
}
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
return subscription;
|
||||
} else {
|
||||
const controller = new AbortController();
|
||||
this.subscriptions.push({callback: cb, requestNr: undefined, abortController: controller});
|
||||
this.send('subscribe', {}, 60_000, false, controller).then((res) => {
|
||||
this.subscriptions = [];
|
||||
if (res.status === "success") {
|
||||
this._subscribe(cb);
|
||||
cb(res);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this._subscribe(cb);
|
||||
}, 1000);
|
||||
}
|
||||
}).catch(() => {
|
||||
this.subscriptions = [];
|
||||
setTimeout(() => {
|
||||
this._subscribe(cb);
|
||||
}, 1000);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribeAll(keepEndpoints: boolean = false) {
|
||||
for (const sub of this.subscriptions) {
|
||||
await this.unsubscribe(sub.requestNr, sub.abortController);
|
||||
}
|
||||
this.subscriptions = [];
|
||||
if (!keepEndpoints) this.subscriptionEndpoints = [];
|
||||
}
|
||||
|
||||
private async unsubscribe(requestNr: number | undefined = undefined, abortController: AbortController | undefined = undefined) {
|
||||
if (this.hasWebsocket()) {
|
||||
await this.send('unsubscribe', {'request_nr': requestNr})
|
||||
} else {
|
||||
if (abortController) abortController.abort('unsubscribe');
|
||||
}
|
||||
}
|
||||
}
|
6
tools/minify-css.sh
Executable file
6
tools/minify-css.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
cd 'dest/www/res/styles' || (echo "minify-css.sh: error" >&2; exit 1)
|
||||
files=$(echo 'styles.css'; grep -E '^@import "(.*)";' 'styles.css' | sed 's/@import "\|";//g')
|
||||
wc -c $files
|
||||
sed ':a;N;$!ba;s/[ \n]\{1,\}/ /g' $files | sed 's/ \?\([{}>;,]\) \?/\1/g' | sed 's/@import[^;]*;\|^ \| $//g' > min.css
|
||||
wc -c min.css
|
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"outDir": "dest/www/res/scripts",
|
||||
"rootDir": "src/",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
@@ -19,20 +19,21 @@ Distributed, end-to-end encrypted instant messaging."/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="icon" sizes="64x64" href="/favicon.ico" type="image/x-icon"/>
|
||||
<link rel="stylesheet" href="/res/styles/styles.css?v=0.0.0-0" type="text/css"/>
|
||||
<script src="/res/js/locutus.js?v=0.0.0-0" type="text/javascript"></script>
|
||||
<script src="/res/js/usimp.js?v=0.0.0-0" type="text/javascript"></script>
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'none';
|
||||
style-src 'self';
|
||||
style-src 'unsafe-inline' 'self';
|
||||
script-src 'self';
|
||||
img-src * blob: data:;
|
||||
connect-src *;
|
||||
media-src * blob: data:;"/>
|
||||
<link rel="stylesheet" href="/res/styles/styles.css" type="text/css"/>
|
||||
<script type="module" src="/res/scripts/usimp.js"></script>
|
||||
<script type="module" src="/res/scripts/locutus.js"></script>
|
||||
<script type="module" src="/res/scripts/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
<main></main>
|
||||
<main style="visibility: hidden;"></main>
|
||||
</div>
|
||||
<footer>
|
||||
<div>Locutus USIMP web client</div>
|
||||
|
@@ -1,168 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
let main, windows;
|
||||
let defaultLocation = '/welcome';
|
||||
|
||||
let token = "";
|
||||
let domain = "";
|
||||
let dest = "";
|
||||
|
||||
function createWelcomeWindow() {
|
||||
let win = document.createElement("div");
|
||||
win.id = "welcome-win";
|
||||
|
||||
win.innerHTML = `
|
||||
<h1>Welcome to Locutus!</h1>
|
||||
<a href="#/login" class="button">Login</a>`;
|
||||
|
||||
while (windows.lastChild) windows.removeChild(windows.lastChild);
|
||||
windows.appendChild(win);
|
||||
}
|
||||
|
||||
function createChatWindow() {
|
||||
let win = document.createElement("div");
|
||||
win.id = "chat-win";
|
||||
|
||||
win.innerHTML = `
|
||||
<div id="message-win">
|
||||
<p>Test Message</p>
|
||||
</div>
|
||||
<input id="sendMessage" name="message" placeholder="Very important message..." type="text">
|
||||
`;
|
||||
|
||||
win.getElementById("sendMessage").addEventListener("keyup", (evt) => {
|
||||
if (evt.keyCode === 13) {
|
||||
evt.preventDefault();
|
||||
//TODO: Send Message
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
main.appendChild(win);
|
||||
}
|
||||
|
||||
async function sendEvent(message) {
|
||||
let res = await fetch(dest, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `usimp ${token}`,
|
||||
},
|
||||
body: message
|
||||
});
|
||||
|
||||
let result = await res;
|
||||
alert(result.message);
|
||||
}
|
||||
|
||||
|
||||
function createLoginWindow() {
|
||||
let win = document.createElement("div");
|
||||
win.id = "login-win";
|
||||
|
||||
win.innerHTML = `
|
||||
<h1>Login to USIMP Account</h1>
|
||||
<form>
|
||||
<input name="account" placeholder="Account name" type="text" required/>
|
||||
<input name="domain" placeholder="Domain" type="text" pattern="([a-zA-Z0-9_-]+\\.)+[a-zA-Z]{2,}" required/>
|
||||
<input name="password" placeholder="Password" type="password" required/>
|
||||
<button type="submit">Login</button>
|
||||
</form>`;
|
||||
|
||||
win.getElementsByTagName("form")[0].addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
let form = evt.target;
|
||||
for (let e of form) e.disabled = true;
|
||||
|
||||
for (let d of form.getElementsByTagName("div")) {
|
||||
form.removeChild(d);
|
||||
}
|
||||
|
||||
function formError(msg) {
|
||||
let div = document.createElement("div");
|
||||
div.classList.add("error");
|
||||
div.innerText = msg;
|
||||
form.appendChild(div);
|
||||
}
|
||||
|
||||
usimp.lookup(form.domain.value)
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
res.json()
|
||||
.then(data => {
|
||||
console.log(data["domain"]);
|
||||
let domainServer = usimp.chooseDomainServer(data["domain_servers"]);
|
||||
console.log(domainServer);
|
||||
dest = "http://" + domainServer.host + ':' + domainServer.protocols.http + "/"
|
||||
|
||||
usimp.login(domainServer, data["domain"].id, form.elements["account"].value, form.elements["password"].value)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
token = data.token;
|
||||
console.log(data.token);
|
||||
})
|
||||
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error(reason);
|
||||
formError("Could not communicate with USIMP domain");
|
||||
})
|
||||
} else {
|
||||
document.getElementsByName("domain")[0].setAttribute("invalid", "invalid");
|
||||
formError("Invalid USIMP domain");
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
document.getElementsByName("domain")[0].setAttribute("invalid", "invalid");
|
||||
formError("Invalid USIMP domain");
|
||||
})
|
||||
.finally(() => {
|
||||
for (let e of form) e.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
while (windows.lastChild) windows.removeChild(windows.lastChild);
|
||||
windows.appendChild(win);
|
||||
|
||||
document.getElementsByName("domain")[0].addEventListener("input", (evt) => {
|
||||
evt.target.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
document.getElementsByName("account")[0].focus();
|
||||
}
|
||||
|
||||
function handleHash(hash) {
|
||||
if (hash[0] === '#') hash = hash.substr(1);
|
||||
|
||||
switch (hash) {
|
||||
case "/welcome": createWelcomeWindow(); break;
|
||||
case "/login": createLoginWindow(); break;
|
||||
default:
|
||||
console.warn(`Invalid url hash #${hash}`);
|
||||
history.replaceState(null, null, `#${defaultLocation}`);
|
||||
handleHash(defaultLocation)
|
||||
return;
|
||||
}
|
||||
|
||||
let url = new URL(document.URL);
|
||||
url.hash = hash;
|
||||
location.href = url.toString();
|
||||
}
|
||||
|
||||
function handleUrl(url) {
|
||||
handleHash(new URL(url).hash);
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", (evt) => {
|
||||
main = document.getElementsByTagName("main")[0];
|
||||
windows = document.getElementById("windows");
|
||||
|
||||
// Remove <noscript> tag
|
||||
document.getElementsByTagName("noscript")[0].remove();
|
||||
|
||||
handleUrl(document.URL)
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", (evt) => {
|
||||
handleUrl(evt.newURL);
|
||||
});
|
@@ -1,49 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
let usimp = {};
|
||||
|
||||
usimp.lookup = function (domain_name) {
|
||||
return fetch(`https://${domain_name}/.well-known/usimp.json`, {
|
||||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
|
||||
usimp.chooseDomainServer = function (domainServers, invalidDomainServers = []) {
|
||||
if (domainServers.length === 0) throw Error("No domain servers specified");
|
||||
|
||||
domainServers.filter(srv => invalidDomainServers.map(srv => srv.id).includes(srv.id));
|
||||
if (domainServers.length === 0) throw Error("No domain servers reachable");
|
||||
if (domainServers.length === 1) return domainServers[0];
|
||||
|
||||
let priority = domainServers.reduce((min, srv) => Math.min(min, srv.priority), Infinity);
|
||||
domainServers = domainServers.filter(srv => srv.priority === priority);
|
||||
if (domainServers.length === 1) return domainServers[0];
|
||||
|
||||
let totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0);
|
||||
let w = Math.floor(Math.random() * totalWeight);
|
||||
|
||||
let accumulator = 0;
|
||||
for (let srv of domainServers) {
|
||||
accumulator += srv.weight;
|
||||
if (w < accumulator) return srv;
|
||||
}
|
||||
|
||||
console.warn("Domain server selection not worked correctly");
|
||||
return domainServers[0];
|
||||
}
|
||||
|
||||
usimp.login = function (domainServer, domain, account, password) {
|
||||
return fetch('http://' + domainServer.host + ':' + domainServer.protocols.http + '/_usimp/authenticate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'To-Domain': domain,
|
||||
'From-Domain': domain
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "password",
|
||||
name: account,
|
||||
password: password,
|
||||
}),
|
||||
});
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
|
||||
* {
|
||||
html {
|
||||
font-family: 'Arial', sans-serif;
|
||||
--bg-win: rgba(192, 192, 192, 0.25);
|
||||
--bg: rgba(224, 224, 224, 0.5);
|
||||
--bg-win: #C0C0C040;
|
||||
--bg: #FFFFFF80;
|
||||
--bg-border: #FFFFFFC0;
|
||||
--fg-soft: rgba(32, 32, 32, 0.5);
|
||||
--footer-height: 2em;
|
||||
}
|
||||
@@ -10,38 +11,21 @@
|
||||
noscript {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: 20vh auto 1em auto;
|
||||
margin: calc(var(--vh, 1vh) * 20) auto 1em auto;
|
||||
max-width: 650px;
|
||||
padding: 2em !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-image: url("/res/images/background.jpg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
min-height: calc(var(--vh, 1vh) * 100);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 800px;
|
||||
height: calc(100% - 2em);
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
div#wrapper {
|
||||
#wrapper {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--footer-height));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
div#wrapper {
|
||||
height: 100vh;
|
||||
}
|
||||
padding: 1em;
|
||||
height: calc(var(--vh, 1vh) * 100 - var(--footer-height));
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@@ -49,7 +33,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
div#windows {
|
||||
#windows {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -59,23 +43,39 @@ div#windows {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
div#windows > * {
|
||||
backdrop-filter: blur(32px);
|
||||
#windows > *,
|
||||
main {
|
||||
border: 1px solid var(--bg-win);
|
||||
background: var(--bg-win);
|
||||
border-radius: 4px;
|
||||
padding: 0.5em 1em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
div#login-win,
|
||||
div#welcome-win {
|
||||
max-width: 650px;
|
||||
margin: calc(max(25vh, 8em) - 8em) auto 1em auto;
|
||||
main {
|
||||
max-width: 800px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
div#login-win h1,
|
||||
div#welcome-win h1 {
|
||||
#windows > * {
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
div.window-login,
|
||||
div.window-welcome {
|
||||
margin: calc(max(var(--vh, 1vh) * 25, 8em) - 8em) auto 1em auto;
|
||||
}
|
||||
|
||||
div.window-login {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
div.window-welcome {
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
div.window-login h1,
|
||||
div.window-welcome h1 {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -85,22 +85,12 @@ footer {
|
||||
border-top: 1px solid var(--bg-win);
|
||||
background: var(--bg-win);
|
||||
height: var(--footer-height);
|
||||
backdrop-filter: blur(32px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
* {
|
||||
--footer-height: 4em;
|
||||
}
|
||||
}
|
||||
|
||||
footer div {
|
||||
font-size: 0.75em;
|
||||
display: inline;
|
||||
@@ -111,23 +101,23 @@ footer div {
|
||||
|
||||
form {
|
||||
max-width: 400px;
|
||||
margin: 1.5em auto;
|
||||
margin: 1.5em auto 1em auto;
|
||||
}
|
||||
|
||||
form input,
|
||||
form input:is([type=text], [type=password]),
|
||||
form button,
|
||||
form fieldset,
|
||||
a.button,
|
||||
form div {
|
||||
border: 1px solid var(--bg);
|
||||
border: 1px solid var(--bg-border);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
padding: 0.5em 1em;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
margin: 1em auto;
|
||||
font-size: 1em;
|
||||
color: #000000;
|
||||
transition: border-color 0.125s, background-color 0.125s;
|
||||
transition: border-color 0.0625s, background-color 0.0625s;
|
||||
}
|
||||
|
||||
form input,
|
||||
@@ -150,8 +140,9 @@ a.button {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
form input,
|
||||
form button {
|
||||
form input:is([type=text], [type=password]),
|
||||
form button,
|
||||
form fieldset {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -186,3 +177,103 @@ form button:active,
|
||||
a.button:active {
|
||||
background-color: rgba(224, 224, 224, 0.75);
|
||||
}
|
||||
|
||||
main .chat {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main .chat-history {
|
||||
height: calc(100% - 3em);
|
||||
overflow-y: scroll;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main .chat-input-wrapper {
|
||||
width: 100%;
|
||||
height: 3em;
|
||||
border-top: 1px solid var(--bg-win);
|
||||
padding: 0.5em 1em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main .chat-input-wrapper input {
|
||||
background-color: rgba(0,0,0,0);
|
||||
border: 1px solid var(--bg-win);
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
padding: 0.25em 1em;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
main .chat .message {
|
||||
margin: 1em;
|
||||
border: 1px solid var(--bg-win);
|
||||
border-radius: 4px;
|
||||
padding: 0.5em 1em;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
width: -moz-fit-content;
|
||||
background-color: rgba(255, 255, 255, 0.875);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
main .chat .message.pending {
|
||||
border-color: #C00000;
|
||||
}
|
||||
|
||||
main .chat ::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
main .chat ::-webkit-scrollbar-track {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
main .chat ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
main .chat ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 100px) {
|
||||
body {
|
||||
background-image: url("/res/images/background.jpg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: top center;
|
||||
background-size: cover;
|
||||
}
|
||||
#windows > * {
|
||||
backdrop-filter: blur(32px);
|
||||
}
|
||||
footer {
|
||||
backdrop-filter: blur(32px);
|
||||
}
|
||||
main .chat-input-wrapper {
|
||||
backdrop-filter: blur(32px);
|
||||
}
|
||||
main .chat-history {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#wrapper {
|
||||
height: calc(var(--vh, 1vh) * 100);
|
||||
padding: 0;
|
||||
}
|
||||
#windows {
|
||||
padding: 0;
|
||||
}
|
||||
footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
html {
|
||||
--footer-height: 4em;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user