import { createClient, PendingEventOrdering, type MatrixClient } from 'matrix-js-sdk' import { config } from '@/config' import { bindCrypto, cryptoCallbacks, cryptoDatabasePrefix } from './crypto' // The one MatrixClient of a signed-in session. It is kept outside Vue's // reactivity on purpose: the SDK's objects mutate themselves and emit // events, and the stores turn those events into plain reactive state. // // Everything that touches matrix-js-sdk lives under src/matrix and // src/stores, so the desktop app can later swap in its Go backend behind // the same stores. export interface Credentials { baseUrl: string userId: string deviceId: string accessToken: string } let current: MatrixClient | null = null let cryptoFailure = '' /** The running client. Only call this while signed in. */ export function client(): MatrixClient { if (!current) throw new Error('Not signed in') return current } export function hasClient(): boolean { return current !== null } /** * Why end-to-end encryption didn't start in this session, or '' when it * runs. Without it the client still works in unencrypted rooms. */ export function cryptoError(): string { return cryptoFailure } /** A client without a session, for sign-in and registration. */ export function anonymousClient(): MatrixClient { return createClient({ baseUrl: config.homeserverUrl }) } export async function startClient(creds: Credentials): Promise { stopClient() const c = createClient({ baseUrl: creds.baseUrl, userId: creds.userId, deviceId: creds.deviceId, accessToken: creds.accessToken, timelineSupport: true, useAuthorizationHeader: true, cryptoCallbacks, // Emoji (SAS) only: there is no camera to scan a QR code with here. verificationMethods: ['m.sas.v1'], }) current = c // End-to-end encryption: the Rust crypto stack (matrix-sdk-crypto-wasm), // its keys in IndexedDB under this session's own name, so two accounts // in one browser never share a store. It loads before the first sync so // the to-device messages and room keys in it are not missed. cryptoFailure = '' try { await c.initRustCrypto({ cryptoDatabasePrefix: cryptoDatabasePrefix(creds.userId, creds.deviceId) }) bindCrypto(c) } catch (e) { console.error('crypto: ', e) cryptoFailure = e instanceof Error ? e.message : String(e) } await c.startClient({ initialSyncLimit: 30, lazyLoadMembers: true, // Your own messages that are still sending (or failed) are kept apart // from the timeline, so a failed one doesn't sit between others' replies. pendingEventOrdering: PendingEventOrdering.Detached, threadSupport: false, }) return c } export function stopClient() { if (!current) return bindCrypto(null) current.stopClient() current.removeAllListeners() current = null } // Crypto stores of sessions that are over but not deleted yet. Deleting // an IndexedDB database waits until every connection to it is closed, and // the crypto stack may hold one until the page goes away; whatever is left // here is deleted when the next page starts. const forgetKey = 'warp.web.crypto.forget' function pendingForget(): string[] { try { const v = JSON.parse(localStorage.getItem(forgetKey) ?? '[]') return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] } } function setPendingForget(list: string[]) { try { if (list.length) localStorage.setItem(forgetKey, JSON.stringify(list)) else localStorage.removeItem(forgetKey) } catch { // Not kept: the store stays until it is deleted by hand. } } /** Deletes a crypto store; false when it is still open after a few seconds. */ async function deleteStore(c: MatrixClient, prefix: string): Promise { const timeout = new Promise((resolve) => window.setTimeout(() => resolve(false), 3000)) const done = c .clearStores({ cryptoDatabasePrefix: prefix }) .then(() => true as const) .catch((e) => { console.error('clearStores: ', e) return false as const }) const ok = await Promise.race([done, timeout]) if (ok) setPendingForget(pendingForget().filter((p) => p !== prefix)) return ok } /** * Stops the client and deletes what it kept in this browser, the * session's encryption keys included. For when the session is over: kept, * they could only ever decrypt for a device the homeserver has forgotten. */ export async function destroyClient(session?: { userId: string; deviceId: string }) { // The session may have no running client (it failed to start), and its // keys still have to go. const c = current ?? anonymousClient() stopClient() const userId = session?.userId ?? c.getUserId() const deviceId = session?.deviceId ?? c.getDeviceId() if (!userId || !deviceId) return const prefix = cryptoDatabasePrefix(userId, deviceId) setPendingForget([...new Set([...pendingForget(), prefix])]) await deleteStore(c, prefix) } /** Finishes deleting the crypto stores of sessions that ended. Runs before any client starts. */ export async function forgetEndedSessions() { const list = pendingForget() if (!list.length) return const c = anonymousClient() for (const prefix of list) await deleteStore(c, prefix) }