summaryrefslogtreecommitdiff
path: root/crates/chat-server/web/app.ts
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/web/app.ts')
-rw-r--r--crates/chat-server/web/app.ts560
1 files changed, 560 insertions, 0 deletions
diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts
new file mode 100644
index 0000000..79a827e
--- /dev/null
+++ b/crates/chat-server/web/app.ts
@@ -0,0 +1,560 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
+
+function element(id: string): HTMLElement {
+ const value = document.getElementById(id);
+ if (!value) throw new Error(`Missing interface element: ${id}`);
+ return value;
+}
+
+function input(id: string): HTMLInputElement {
+ const value = element(id);
+ if (!(value instanceof HTMLInputElement)) throw new Error(`Expected input: ${id}`);
+ return value;
+}
+
+function form(id: string): HTMLFormElement {
+ const value = element(id);
+ if (!(value instanceof HTMLFormElement)) throw new Error(`Expected form: ${id}`);
+ return value;
+}
+
+function messageInput(): HTMLTextAreaElement {
+ const value = element('text');
+ if (!(value instanceof HTMLTextAreaElement)) throw new Error('Missing message input');
+ return value;
+}
+
+const show = (id: string, visible = true): void => { element(id).hidden = !visible; };
+const setStatus = (message: string): void => { element('status').textContent = message; };
+const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Unexpected operation failure';
+let config: StackChat.Configuration | undefined;
+let wasm: Promise<ArrayBuffer> | undefined;
+let credentials: StackChat.Credentials | undefined;
+let session: StackChat.Session | undefined;
+let selected: StackChat.Conversation | undefined;
+let before = 0;
+let pendingFile: string | undefined;
+let setupToken: string | undefined;
+let pendingSend: StackChat.SendRequest | undefined;
+let generation = 0;
+let busy = false;
+const workers = new Map<Worker, () => void>();
+const blobs = new Set<string>();
+
+function configuration(): StackChat.Configuration {
+ if (!config) throw new Error('Chat configuration is not loaded');
+ return config;
+}
+
+function ownerKey(): string {
+ const owner = configuration().owner;
+ if (!owner) throw new Error('Owner setup is required first');
+ return owner;
+}
+
+function activeSession(): StackChat.Session {
+ if (!session) throw new Error('Unlock your identity file first');
+ return session;
+}
+
+function conversation(): StackChat.Conversation {
+ if (!selected) throw new Error('Choose a conversation first');
+ return selected;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function record(value: unknown): Record<string, unknown> {
+ if (!isRecord(value)) throw new Error('Invalid response object');
+ return value;
+}
+
+function stringField(value: Record<string, unknown>, key: string): string {
+ const field = value[key];
+ if (typeof field !== 'string') throw new Error(`Invalid response field: ${key}`);
+ return field;
+}
+
+function countField(value: Record<string, unknown>, key: string): number {
+ const field = value[key];
+ if (typeof field !== 'number' || !Number.isSafeInteger(field) || field < 0) throw new Error(`Invalid response count: ${key}`);
+ return field;
+}
+
+function booleanField(value: Record<string, unknown>, key: string): boolean {
+ const field = value[key];
+ if (typeof field !== 'boolean') throw new Error(`Invalid response flag: ${key}`);
+ return field;
+}
+
+function arrayField(value: Record<string, unknown>, key: string): unknown[] {
+ const field: unknown = value[key];
+ if (!Array.isArray(field)) throw new Error(`Invalid response list: ${key}`);
+ return field;
+}
+
+function decodeConversation(value: unknown): StackChat.Conversation {
+ const item = record(value);
+ const total = countField(item, 'total');
+ const visitor = countField(item, 'visitor_sent');
+ const owner = countField(item, 'owner_sent');
+ if (total > 10000 || visitor + owner !== total) throw new Error('Invalid conversation count');
+ const profile = item['profile'];
+ if (profile !== undefined && profile !== null && typeof profile !== 'string') throw new Error('Invalid conversation profile');
+ return { id: stringField(item, 'id'), public: stringField(item, 'public'), total, visitor_sent: visitor, owner_sent: owner, ...(profile === undefined ? {} : { profile }) };
+}
+
+function decodeMessage(value: unknown): StackChat.Message {
+ const item = record(value);
+ return { seq: countField(item, 'seq'), id: stringField(item, 'id'), sender: stringField(item, 'sender'), ciphertext: stringField(item, 'ciphertext') };
+}
+
+function decodeDecryption(value: unknown): StackChat.Decryption {
+ const item = record(value);
+ return { signer: stringField(item, 'signer'), payload: stringField(item, 'payload') };
+}
+
+const apiReaders: { [K in keyof StackChat.Api]: (value: unknown) => StackChat.Api[K]['response'] } = {
+ config(value) {
+ const item = record(value);
+ const owner = item['owner'];
+ if (owner !== null && typeof owner !== 'string') throw new Error('Invalid owner configuration');
+ return { owner, origin: stringField(item, 'origin'), protocol: stringField(item, 'protocol') };
+ },
+ challenge(value) { const item = record(value); return { id: stringField(item, 'id'), challenge: stringField(item, 'challenge') }; },
+ authenticate(value) { const item = record(value); return { token: stringField(item, 'token'), id: stringField(item, 'id'), owner: booleanField(item, 'owner'), expires_seconds: countField(item, 'expires_seconds') }; },
+ profile(value) { return { stored: booleanField(record(value), 'stored') }; },
+ inbox(value) { return { conversations: arrayField(record(value), 'conversations').map(decodeConversation) }; },
+ history(value) { const item = record(value); return { conversation: decodeConversation(item['conversation']), messages: arrayField(item, 'messages').map(decodeMessage) }; },
+ send(value) { const item = record(value); return { seq: countField(item, 'seq'), conversation: decodeConversation(item['conversation']) }; },
+ lock(value) { return { locked: booleanField(record(value), 'locked') }; },
+};
+
+const cryptoReaders: { [K in keyof StackChat.Crypto]: (value: unknown) => StackChat.Crypto[K]['response'] } = {
+ create(value) { return { file: stringField(record(value), 'file') }; },
+ open(value) { const item = record(value); return { public: stringField(item, 'public'), fingerprint: stringField(item, 'fingerprint'), owner: stringField(item, 'owner'), name: stringField(item, 'name') }; },
+ proof(value) { return { signature: stringField(record(value), 'signature') }; },
+ encrypt(value) { return { ciphertext: stringField(record(value), 'ciphertext') }; },
+ decrypt: decodeDecryption,
+ decrypt_batch(value) {
+ return { messages: arrayField(record(value), 'messages').map(entry => {
+ const item = record(entry);
+ return 'error' in item ? { error: stringField(item, 'error') } : decodeDecryption(item);
+ }) };
+ },
+};
+
+async function api<K extends keyof StackChat.Api>(request: StackChat.Api[K]['request'] & { op: K }): Promise<StackChat.Api[K]['response']> {
+ const current = generation;
+ const response = await fetch('/api', {
+ method: 'POST', credentials: 'omit', cache: 'no-store', redirect: 'error',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...request, token: session?.token }),
+ signal: AbortSignal.timeout(20000),
+ });
+ const result = record(await response.json());
+ if (current !== generation) throw new Error('Session locked');
+ if (!response.ok || result['error']) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed');
+ return apiReaders[request.op](result);
+}
+
+async function cryptoOperation<K extends keyof StackChat.Crypto>(request: StackChat.Crypto[K]['request'] & { op: K }): Promise<StackChat.Crypto[K]['response']> {
+ const current = generation;
+ if (!wasm) throw new Error('Cryptographic module is not loaded');
+ const bytes = await wasm;
+ if (current !== generation) throw new Error('Session locked');
+ return new Promise<StackChat.Crypto[K]['response']>((resolve, reject) => {
+ const worker = new Worker('/worker.js');
+
+ const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); };
+ const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000);
+ workers.set(worker, () => { stop(); reject(new Error('Session locked')); });
+ worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); };
+ worker.onmessage = (event: MessageEvent<unknown>) => {
+ stop();
+ if (current !== generation) { reject(new Error('Session locked')); return; }
+ try {
+ const message = record(event.data);
+ if ('error' in message) throw new Error(stringField(message, 'error'));
+ resolve(cryptoReaders[request.op](message['result']));
+ } catch (error) { reject(error); }
+ };
+ worker.postMessage({ bytes, request });
+ });
+}
+
+function protectRequest<K extends 'encrypt' | 'decrypt' | 'decrypt_batch'>(op: K, fields: Omit<StackChat.Crypto[K]['request'], keyof StackChat.Protected | 'op'>): StackChat.Protected & { op: K } & typeof fields {
+ if (!credentials) throw new Error('Unlock your identity file first');
+ return { op, ...credentials, origin: location.origin, ...fields };
+}
+
+function clearState() {
+ generation += 1;
+ busy = false;
+ for (const button of document.querySelectorAll('button')) button.disabled = false;
+ for (const cancel of workers.values()) cancel();
+ workers.clear();
+ for (const url of blobs) URL.revokeObjectURL(url);
+ blobs.clear();
+ credentials = undefined;
+ session = undefined;
+ selected = undefined;
+ pendingFile = undefined;
+ pendingSend = undefined;
+ setupToken = undefined;
+ before = 0;
+ input('history-count').max = '0';
+ element('history-range').textContent = 'Latest messages (0-0)';
+ for (const input of document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input,textarea')) {
+ if (input.id === 'history-count') input.value = '0'; else input.value = '';
+ }
+ for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren();
+ for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false);
+ show('entry', Boolean(config?.owner));
+ show('owner-entry');
+ setStatus('Locked. Select your file and enter its password to return.');
+}
+
+function action(id: string, callback: () => Promise<void>): void {
+ element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => {
+ event.preventDefault();
+ if (busy) return;
+ busy = true;
+ setStatus('Working locally...');
+ const current = generation;
+ for (const button of document.querySelectorAll<HTMLButtonElement>('button:not(#lock)')) button.disabled = true;
+ try { await callback(); }
+ catch (error) { if (generation === current) setStatus(errorMessage(error)); }
+ finally {
+ if (generation === current) {
+ busy = false;
+ for (const button of document.querySelectorAll('button')) button.disabled = false;
+ }
+ }
+ });
+}
+
+function download(file: string): void {
+ const url = URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' }));
+ blobs.add(url);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'stack-chat-identity.chat';
+ link.click();
+ setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000);
+}
+
+async function createIdentity() {
+ const password = input('new-password').value;
+ if (password !== input('confirm-password').value) throw new Error('Passwords do not match');
+ if (!configuration().owner && element('setup-label').hidden) throw new Error('Owner setup is required first');
+ setupToken = !configuration().owner ? input('setup-token').value : undefined;
+ setStatus('Generating your purpose-specific identity and protecting its file...');
+ const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner || '' });
+ pendingFile = result.file;
+ download(result.file);
+ form('create-form').reset();
+ show('create', false);
+ show('unlock');
+ element('reopen-note').textContent = 'Your file was downloaded. Reopen that exact saved file and enter its password before any message can be sent. Keep a safe backup.';
+ setStatus('Reopen your downloaded identity file to verify recovery.');
+}
+
+async function unlockIdentity() {
+ const file = input('file').files?.[0];
+ if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB');
+ const fileText = await file.text();
+ if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded');
+ const candidate = { file: fileText, password: input('password').value };
+ form('unlock-form').reset();
+ setStatus('Unlocking locally and verifying your identity...');
+ const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin });
+ if (configuration().owner && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.');
+ const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken });
+ const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge });
+ const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature });
+ if (authenticated.id !== identity.fingerprint || typeof authenticated.owner !== 'boolean') throw new Error('Authenticated identity mismatch');
+ credentials = candidate;
+ session = { ...identity, ...authenticated };
+ pendingFile = undefined;
+ setupToken = undefined;
+ config = await api({ op: 'config' });
+ show('entry', false);
+ show('owner-entry', false);
+ show('owner-access', false);
+ show('unlock', false);
+ show('chat');
+ element('who').textContent = `${identity.name} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`;
+ element('fingerprint').textContent = `Identity: ${identity.fingerprint}`;
+ if (authenticated.owner) {
+ activeSession().owner = true;
+ show('inbox');
+ await inbox();
+ } else {
+ activeSession().owner = false;
+ const profile = await cryptoOperation(protectRequest('encrypt', { recipient: ownerKey(), payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: activeSession().id, sender: activeSession().id, name: identity.name }) }));
+ await api({ op: 'profile', conversation: activeSession().id, ciphertext: profile.ciphertext });
+ selected = { id: activeSession().id, public: identity.public, total: 0, visitor_sent: 0, owner_sent: 0 };
+ element('conversation-fingerprint').textContent = `Conversation: ${activeSession().id}`;
+ show('thread');
+ element('thread-name').textContent = 'You <-> Doan';
+ await loadHistory(0);
+ }
+ setStatus('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.');
+}
+
+function checkedPayload(payload: string, kind: 'profile', conversation: string, sender: string): { name: string };
+function checkedPayload(payload: string, kind: 'message', conversation: string, sender: string): { id: string; text: string };
+function checkedPayload(payload: string, kind: 'profile' | 'message', conversation: string, sender: string): { name: string } | { id: string; text: string } {
+ const value = record(JSON.parse(payload));
+ if (value['v'] !== 1 || value['kind'] !== kind || value['origin'] !== location.origin || value['conversation'] !== conversation || value['sender'] !== sender) throw new Error('Signed message binding mismatch');
+ if (kind === 'profile') return { name: stringField(value, 'name') };
+ return { id: stringField(value, 'id'), text: stringField(value, 'text') };
+}
+
+async function inbox() {
+ const result = await api({ op: 'inbox' });
+ element('conversations').replaceChildren();
+ for (const conversation of result.conversations) {
+ const button = document.createElement('button');
+ button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`;
+ button.addEventListener('click', async () => {
+ if (busy) return;
+ if (pendingSend) { setStatus('Retry the pending message, or lock before switching conversations.'); return; }
+ busy = true;
+ const current = generation;
+ setStatus('Opening conversation...');
+ try {
+ selected = conversation;
+ element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`;
+ element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`;
+ pendingSend = undefined;
+ messageInput().value = '';
+ show('retry', false);
+ element('messages').replaceChildren();
+ show('thread');
+ let name = conversation.id.slice(0, 12);
+ if (conversation.profile) {
+ const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile }));
+ if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch');
+ const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id);
+ if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile');
+ name = profile.name;
+ }
+ element('thread-name').textContent = `Doan <-> ${name}`;
+ await loadHistory(0);
+ setStatus('Conversation opened. Choose how many messages to load.');
+ } catch (error) { setStatus(errorMessage(error)); }
+ finally { if (generation === current) busy = false; }
+ });
+ element('conversations').append(button);
+ }
+}
+
+function counters(conversation: StackChat.Conversation): void {
+ if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count');
+ input('history-count').max = String(conversation.total);
+ element('history-range').textContent = `Latest messages (0-${conversation.total})`;
+ const sent = activeSession().owner ? conversation.owner_sent : conversation.visitor_sent;
+ const received = activeSession().owner ? conversation.visitor_sent : conversation.owner_sent;
+ element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`;
+}
+
+async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> {
+ const requests = messages.map(message => ({ sender: message.sender === conversation().id ? conversation().public : ownerKey(), ciphertext: message.ciphertext }));
+ const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests }));
+ for (let index = 0; index < messages.length; index += 1) {
+ const message = messages[index];
+ if (!message) throw new Error('Missing history message');
+ const item = document.createElement('li');
+ const label = document.createElement('small');
+ label.textContent = `#${message.seq} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`;
+ const text = document.createElement('p');
+ try {
+ const entry = decrypted.messages[index];
+ if (!entry || 'error' in entry) throw new Error('Invalid ciphertext or signature');
+ if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch');
+ const payload = checkedPayload(entry.payload, 'message', conversation().id, message.sender);
+ if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch');
+ text.textContent = payload.text;
+ } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; }
+ item.append(label, text);
+ element('messages').prepend(item);
+ }
+}
+
+async function refreshCounts() {
+ const conversationId = conversation().id;
+ const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 });
+ if (selected?.id === conversationId) counters(result.conversation);
+ return result.conversation;
+}
+
+async function loadHistory(count: number, cursor = 0): Promise<void> {
+ if (!selected) throw new Error('Choose a conversation first');
+ const currentConversation = await refreshCounts();
+ if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${currentConversation.total} messages`);
+ if (!cursor) { element('messages').replaceChildren(); before = 0; }
+ if (count === 0) {
+ input('history-count').value = '0';
+ show('older', false);
+ setStatus('No message history loaded.');
+ return;
+ }
+ let remaining = count;
+ let next = cursor;
+ let exhausted = false;
+ while (remaining > 0) {
+ const size = Math.min(50, remaining);
+ const result = await api({ op: 'history', conversation: conversation().id, count: size, before: next });
+ counters(result.conversation);
+ if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response');
+ if (!result.messages.length) { exhausted = true; break; }
+ const sequence = Math.min(...result.messages.map(message => message.seq));
+ if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor');
+ setStatus(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`);
+ await renderHistoryBatch(result.messages);
+ remaining -= result.messages.length;
+ next = sequence;
+ before = next;
+ if (result.messages.length < size) { exhausted = true; break; }
+ }
+ show('older', !exhausted && element('messages').children.length < Number(input('history-count').max));
+ setStatus('Selected history decrypted and verified locally.');
+}
+
+async function submitPending() {
+ if (!pendingSend) throw new Error('No pending message');
+ const result = await api(pendingSend);
+ counters(result.conversation);
+ pendingSend = undefined;
+ messageInput().value = '';
+ show('retry', false);
+ setStatus('Encrypted message stored. Load history to read messages.');
+}
+
+async function send() {
+ if (!selected) throw new Error('Choose a conversation first');
+ if (pendingSend) throw new Error('Retry or lock before composing another message');
+ const text = messageInput().value;
+ if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text');
+ const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
+ const recipient = activeSession().owner ? conversation().public : ownerKey();
+ setStatus('Signing and encrypting for both participants locally...');
+ const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: conversation().id, sender: activeSession().id, id, text }) }));
+ pendingSend = { op: 'send', conversation: conversation().id, id, ciphertext: result.ciphertext };
+ show('retry');
+ await submitPending();
+}
+
+function ownerSetup() {
+ show('entry', false);
+ show('owner-entry', false);
+ show('unlock', false);
+ show('create', !configuration().owner);
+ show('owner-access', Boolean(configuration().owner));
+ show('setup-label', !configuration().owner);
+ element('create-title').textContent = 'Create the owner identity (owner only)';
+ setStatus('');
+}
+
+action('create-choice', async () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); });
+action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); });
+action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
+action('owner-open', async () => { show('owner-access', false); show('unlock'); setStatus(''); });
+action('create-form', createIdentity);
+action('unlock-form', unlockIdentity);
+element('history-count').addEventListener('focus', () => {
+ const current = generation;
+ if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) setStatus(errorMessage(error)); });
+});
+action('history-form', () => loadHistory(Number(input('history-count').value)));
+action('send-form', send);
+action('retry', submitPending);
+action('refresh-inbox', inbox);
+action('older', () => loadHistory(Number(input('history-count').value), before));
+element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); });
+window.addEventListener('pagehide', clearState);
+window.addEventListener('pageshow', event => { if (event.persisted) clearState(); });
+
+async function downloadCrypto() {
+ const controller = new AbortController();
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ let reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>> | undefined;
+ const refreshTimeout = () => {
+ clearTimeout(timer);
+ timer = setTimeout(() => controller.abort(), 45000);
+ };
+ try {
+ refreshTimeout();
+ const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal });
+ if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`);
+ const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length'));
+ const total = Number.isSafeInteger(length) && length > 0 ? length : 0;
+ const chunks: Uint8Array<ArrayBuffer>[] = [];
+ let received = 0;
+ const progress = () => {
+ const loaded = (received / 1048576).toFixed(1);
+ if (total) {
+ const percent = Math.min(100, Math.floor(received * 100 / total));
+ const filled = Math.floor(percent / 5);
+ setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`);
+ } else setStatus(`Downloading cryptographic module... ${loaded} MiB received`);
+ };
+ progress();
+ if (!response.body) throw new Error('Cryptographic module response has no body');
+ reader = response.body.getReader();
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ refreshTimeout();
+ chunks.push(value);
+ received += value.byteLength;
+ progress();
+ }
+ if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.');
+ const bytes = new Uint8Array(received);
+ let offset = 0;
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
+ return bytes.buffer;
+ } catch (error) {
+ if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.');
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ reader?.releaseLock();
+ }
+}
+
+async function start() {
+ wasm = downloadCrypto();
+ await wasm;
+ setStatus('Cryptographic module downloaded. Connecting to chat...');
+ config = await api({ op: 'config' });
+ if (configuration().origin !== location.origin) throw new Error('Server origin mismatch');
+ show('owner-entry');
+ show('entry', Boolean(configuration().owner));
+ setStatus('');
+ if (location.hash === '#setup') ownerSetup();
+}
+
+start().catch(error => setStatus(errorMessage(error)));
+
+// @license-end