Compare commits

...

30 Commits

Author SHA1 Message Date
f656079c7e Restyle login window 2022-08-29 23:21:23 +02:00
0c0dafbecf Add websocket ping 2022-08-29 22:45:10 +02:00
2cce1a3dbf Enforce type cheking for server communication 2022-08-29 19:39:38 +02:00
7a0a2dc3e5 Add Session.genRequestNumDiscriminator() 2022-08-29 16:12:31 +02:00
494e297224 Use localStorage 2022-08-29 14:31:11 +02:00
01a7bed1c9 Fix this.ping() 2022-08-29 00:13:33 +02:00
e8fe008e99 Fix unsubscribtion 2022-08-29 00:09:05 +02:00
7eb6d6a75f Rework media queries for background image 2022-08-28 12:31:19 +02:00
753ecec0b6 Fix window height for mobile devices 2022-08-28 11:24:13 +02:00
c90de1a895 Add minify-css.sh 2022-08-27 22:53:20 +02:00
6f76004dc0 Implement pending/delivered status for messages 2022-08-27 22:34:10 +02:00
b480d35cb1 Changed build process 2022-08-27 22:11:19 +02:00
5fdf49555f Implement mobile connection resumes 2022-08-27 02:59:38 +02:00
426e120e21 Update room id 2022-08-18 21:18:59 +02:00
0217ea90e2 Fixed error handling 2021-06-05 15:05:03 +02:00
5b2deaa346 Subscriptions working 2021-06-05 14:17:58 +02:00
43f26e8ea9 WebSockets working 2021-06-04 15:19:06 +02:00
787b198ffc Hide main by default 2021-05-26 21:38:34 +02:00
826f566ad2 Added custom scrollbar 2021-05-26 20:20:56 +02:00
2482ef02bd Fix for imports 2021-05-25 21:17:15 +02:00
37c9431c29 Refactored tsconfig.json 2021-05-25 19:38:26 +02:00
0d7cd28ca9 using TypeScript 2021-05-25 19:33:39 +02:00
968ede6f58 Sessions working 2021-05-24 22:50:16 +02:00
b8b2ec2ebc Timeout on subscribe fail 2021-05-24 16:06:16 +02:00
2b2a9a6758 Small js fixes 2021-05-24 15:46:02 +02:00
9987086bf0 using const instead of let 2021-05-24 13:40:56 +02:00
eea681d435 changed to async 2021-05-24 13:33:46 +02:00
0601f52661 Refactored base structure 2021-05-24 00:58:17 +02:00
eacddc0667 Message input now required 2021-05-22 22:59:31 +02:00
998e93968f subscribing to events 2021-05-22 22:36:13 +02:00
11 changed files with 1235 additions and 274 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
.idea/
dest/
/*.sh

21
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}
}

View File

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

View File

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

View File

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

View File

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