CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m48s
initClient awaited navigator.storage.persist() before creating the client. Chromium and WebKit answer at once, but Firefox shows a permission prompt and leaves the promise pending until it's answered, so the app sat on "Heating up" (no sync, no crypto, no IndexedDB) for every Firefox user who didn't notice the prompt. The request is still made (KE-1 only needs that), but startup waits at most 1.5 s for the answer. The comment claiming persist() never prompts is corrected. Verified in Playwright Firefox with the prompt left unanswered (persist() still pending): before, still "Heating up" after 20 s; now the room list after 12 s. Fixes #303 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
191 lines
7.6 KiB
TypeScript
191 lines
7.6 KiB
TypeScript
import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
|
|
|
|
import { cryptoCallbacks } from './secretStorageKeys';
|
|
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
|
import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
|
|
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
|
import { revokeOidcTokens } from './oidcLogout';
|
|
import { pushSessionToSW } from '../sw-session';
|
|
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
|
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
|
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
|
|
|
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
|
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
|
export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
|
|
|
/**
|
|
* KE-1 mitigation. Ask the browser to make this origin's storage persistent so the
|
|
* IndexedDB **crypto store** isn't evicted from under a surviving `localStorage`
|
|
* session. When that happens the device "resurrects" with a blank key store and the
|
|
* client re-uploads a one-time key at an id Synapse already holds → a permanent
|
|
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
|
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
|
|
* (engagement / PWA install); Firefox shows a permission prompt and the promise
|
|
* stays pending until it's answered. Denial/absence is non-fatal.
|
|
*/
|
|
const PERSIST_WAIT_MS = 1500;
|
|
|
|
export const requestPersistentStorage = async (): Promise<boolean> => {
|
|
try {
|
|
if (!navigator.storage?.persist) return false;
|
|
if (await navigator.storage.persisted()) return true;
|
|
const granted = await navigator.storage.persist();
|
|
if (!granted) {
|
|
console.warn('Persistent storage not granted — the crypto store remains evictable (KE-1).');
|
|
}
|
|
return granted;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|
// Protect the crypto store from eviction before we create and write to it.
|
|
// [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
|
|
// the promise pending until the user responds, which froze the app on
|
|
// "Heating up". KE-1 only needs the request made; give it 1.5 s at most.
|
|
await Promise.race([
|
|
requestPersistentStorage(),
|
|
new Promise<boolean>((resolve) => {
|
|
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
|
|
}),
|
|
]);
|
|
|
|
const indexedDBStore = new IndexedDBStore({
|
|
indexedDB: globalThis.indexedDB,
|
|
localStorage: globalThis.localStorage,
|
|
dbName: 'web-sync-store',
|
|
});
|
|
|
|
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
|
|
|
|
// OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh
|
|
// (the client calls this reactively on a 401) and persist rotated tokens.
|
|
const oidcRefresher =
|
|
session.refreshToken && session.oidc
|
|
? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl)
|
|
: undefined;
|
|
|
|
const mx = createClient({
|
|
baseUrl: session.baseUrl,
|
|
accessToken: session.accessToken,
|
|
refreshToken: session.refreshToken,
|
|
userId: session.userId,
|
|
store: indexedDBStore,
|
|
cryptoStore: legacyCryptoStore,
|
|
deviceId: session.deviceId,
|
|
timelineSupport: true,
|
|
cryptoCallbacks: cryptoCallbacks as any,
|
|
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
|
|
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
|
|
tokenRefreshFunction: oidcRefresher
|
|
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
|
: undefined,
|
|
});
|
|
|
|
try {
|
|
await indexedDBStore.startup();
|
|
} catch (e) {
|
|
// IDB VersionError = local DB was written by a newer SDK version (schema downgrade).
|
|
if (e instanceof DOMException && e.name === 'VersionError') {
|
|
throw new Error(IDB_VERSION_CONFLICT);
|
|
}
|
|
throw e;
|
|
}
|
|
await mx.initRustCrypto();
|
|
|
|
mx.setMaxListeners(150);
|
|
mx.matrixRTC.setMaxListeners(150);
|
|
|
|
return mx;
|
|
};
|
|
|
|
export const startClient = async (mx: MatrixClient) => {
|
|
await mx.startClient({
|
|
lazyLoadMembers: true,
|
|
// P3-8: partition m.thread relations into Thread objects/timelines. Thread
|
|
// replies leave the main timeline (roots stay + get a summary chip); the
|
|
// thread panel renders them. markAsRead sends UNTHREADED receipts
|
|
// (utils/notifications.ts) so room badges keep clearing.
|
|
threadSupport: true,
|
|
});
|
|
};
|
|
|
|
export const clearCacheAndReload = async (mx: MatrixClient) => {
|
|
mx.stopClient();
|
|
clearNavToActivePathStore(mx.getSafeUserId());
|
|
// [Gitea #57] Soundboard clip blob URLs hold decrypted media reachable for
|
|
// the whole page session — revoke them alongside the rest of the caches.
|
|
clearSoundboardClipCache();
|
|
await mx.store.deleteAllData();
|
|
window.location.reload();
|
|
};
|
|
|
|
export const logoutClient = async (mx: MatrixClient) => {
|
|
pushSessionToSW();
|
|
mx.stopClient();
|
|
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
|
|
const session = getFallbackSession();
|
|
if (session?.oidc) {
|
|
await revokeOidcTokens(session);
|
|
}
|
|
try {
|
|
await mx.logout();
|
|
} catch {
|
|
// ignore if failed to logout
|
|
}
|
|
await mx.clearStores();
|
|
// The opt-in local search index stores decrypted plaintext — always wipe it
|
|
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
|
|
await deleteSearchCacheDatabase();
|
|
// Other localStorage caches also hold decrypted plaintext / PII (translation,
|
|
// scheduled messages, recent searches/forwards/gifs/stickers, nav paths) —
|
|
// wipe them too.
|
|
clearPlaintextCaches(mx.getUserId() ?? undefined);
|
|
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
|
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
|
clearSoundboardClipCache();
|
|
// Remove only the session credential keys, preserving user preferences and
|
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
|
removeFallbackSession();
|
|
window.location.reload();
|
|
};
|
|
|
|
// Gitea #45 — deleteSearchCacheDatabase()'s onblocked handling is a bounded
|
|
// best-effort wait; if another tab held `lotus-search-cache` open through it
|
|
// (the pre-broadcast case, or a browser without BroadcastChannel), the delete
|
|
// stayed queued and plaintext survived on disk despite logout "succeeding".
|
|
// Complete it on the next boot with no session, once per boot — call this
|
|
// wherever the app decides there is no session to hydrate (e.g. alongside the
|
|
// existing getFallbackSession() checks in src/index.tsx).
|
|
let bootCleanupRan = false;
|
|
export const cleanupSearchCacheIfSignedOut = async (): Promise<void> => {
|
|
const hasSession = !!getFallbackSession();
|
|
if (!shouldRunBootCleanup(hasSession, bootCleanupRan)) return;
|
|
bootCleanupRan = true;
|
|
await deleteSearchCacheDatabase();
|
|
};
|
|
|
|
export const clearLoginData = async () => {
|
|
const dbs = await window.indexedDB.databases();
|
|
|
|
dbs.forEach((idbInfo) => {
|
|
const { name } = idbInfo;
|
|
if (name) {
|
|
window.indexedDB.deleteDatabase(name);
|
|
}
|
|
});
|
|
|
|
// Unregister service workers so stale caches don't interfere after a reset
|
|
if ('serviceWorker' in navigator) {
|
|
const regs = await navigator.serviceWorker.getRegistrations();
|
|
await Promise.all(regs.map((r) => r.unregister()));
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(cacheNames.map((c) => caches.delete(c)));
|
|
}
|
|
|
|
window.localStorage.clear();
|
|
window.location.reload();
|
|
};
|