feat(desktop): keep the login tokens only in the OS keychain (#105 step 2) #445

Merged
jared merged 1 commits from feat/keychain-session into lotus 2026-10-23 20:59:25 -04:00
6 changed files with 571 additions and 75 deletions
+69
View File
@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Box, Button, Text, config } from 'folds';
import { removeFallbackSession } from '../state/sessions';
import type { KeychainBootResult } from '../state/keychainSession';
import type { KeychainInvoke } from '../state/keychainMirror';
/**
* [Gitea #105] Shown instead of the app when the desktop app's sign-in is kept
* in the OS keychain but couldn't be read this time. Never a silent logout:
* Retry boots again; signing in again is an explicit choice.
*/
export function KeychainUnavailable({
boot,
invoke,
}: {
boot: KeychainBootResult;
invoke: KeychainInvoke | undefined;
}) {
const [busy, setBusy] = useState(false);
const missing = boot.mode === 'missing';
const signInAgain = async () => {
setBusy(true);
removeFallbackSession();
await invoke?.('secure_session_clear').catch(() => undefined);
window.location.reload();
};
return (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ minHeight: '100vh', padding: config.space.S700, textAlign: 'center' }}
>
<Text size="H3">Couldn&apos;t read your sign-in</Text>
<Text size="T300" style={{ maxWidth: 460 }}>
{missing
? 'Lotus Chat keeps your sign-in in Windows Credential Manager, but the entry is missing. If you removed it, sign in again.'
: 'Lotus Chat keeps your sign-in in Windows Credential Manager and couldn’t read it just now. This is usually temporary.'}
</Text>
{boot.mode === 'error' && (
<Text size="T200" priority="300">
{boot.error}
</Text>
)}
<Box gap="200">
<Button
variant="Primary"
size="400"
radii="300"
disabled={busy}
onClick={() => window.location.reload()}
>
<Text size="B400">Retry</Text>
</Button>
<Button
variant="Secondary"
fill="Soft"
size="400"
radii="300"
disabled={busy}
onClick={signInAgain}
>
<Text size="B400">Sign in again</Text>
</Button>
</Box>
</Box>
);
}
+19 -8
View File
@@ -1,14 +1,19 @@
import { useEffect, useState } from 'react';
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
import {
getFallbackSession,
onSessionPersisted,
Session,
stripTokensFromStorage,
} from './sessions';
/**
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
* [Gitea #105] Desktop: keep the login tokens in the OS keychain.
*
* The session is still read from localStorage exactly as before; this only
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
* reading it back, so real installs prove the keychain works before step 2
* switches reads over to it. Nothing here can log anyone out: every failure
* just records a status for Settings.
* Step 1 mirrored them here and verified by reading back. Step 2 (sessions.ts,
* keychainSession.ts) reads them from here at boot; once a write is verified,
* the tokens are dropped from localStorage. Nothing here can log anyone out:
* a failure keeps the tokens in localStorage and records a status for
* Settings.
*
* Writes are serialized (a token rotation right after login must not race
* the login's write) and time out, so a hung credential store can't pile up.
@@ -122,7 +127,13 @@ export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void =>
started = true;
let chain: Promise<unknown> = Promise.resolve();
const enqueue = (session: Session | null) => {
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
chain = chain.then(async () => {
const result = await syncKeychain(invoke, session);
setStatus(result);
// [Gitea #105] step 2: the keychain verifiably holds these tokens, so
// localStorage can drop them (only if they're still the current ones).
if (result.state === 'ok' && session) stripTokensFromStorage(tokensOf(session));
});
};
enqueue(getFallbackSession() ?? null);
onSessionPersisted((session) => enqueue(session));
+183
View File
@@ -0,0 +1,183 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// localStorage stand-in (these modules read it at call time).
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
};
const sessions = await import('./sessions');
const { bootKeychainSession } = await import('./keychainSession');
const { syncKeychain, tokensOf } = await import('./keychainMirror');
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
const keychain = (opts: { getThrows?: boolean; setThrows?: boolean } = {}) => {
let stored: unknown = null;
const invoke: Invoke = async (cmd, args) => {
if (cmd === 'secure_session_supported') return true;
if (cmd === 'secure_session_get') {
if (opts.getThrows) throw new Error('Element not found.');
return stored;
}
if (cmd === 'secure_session_set') {
if (opts.setThrows) throw new Error('Access is denied.');
stored = args?.tokens;
return null;
}
if (cmd === 'secure_session_clear') {
stored = null;
return null;
}
throw new Error(cmd);
};
return {
invoke,
get: () => stored,
set: (v: unknown) => {
stored = v;
},
};
};
const blob = () => JSON.parse(store.get('cinny_session_v1') ?? 'null');
const tokensOnDisk = () =>
[...store.entries()].some(([, v]) => v.includes('syt_') || v.includes('mar_'));
const login = (access: string, refresh?: string) =>
sessions.setFallbackSession(access, 'DEV1', '@alice:example.org', 'https://hs.example', {
refreshToken: refresh,
expiresInMs: 300_000,
});
/** A fresh page load: drop the in-memory state, keep storage and keychain. */
const reload = () => sessions.leaveKeychainMode();
/** What the mirror does after a write: verify in the keychain, then strip. */
const mirror = async (invoke: Invoke) => {
const s = sessions.getFallbackSession() ?? null;
const r = await syncKeychain(invoke, s);
if (r.state === 'ok' && s) sessions.stripTokensFromStorage(tokensOf(s));
return r;
};
test('[#105] web / no keychain: localStorage stays authoritative', async () => {
store.clear();
reload();
login('syt_web', 'mar_web');
assert.deepEqual(await bootKeychainSession(undefined), { mode: 'off' });
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_web');
assert.equal(blob().accessToken, 'syt_web');
});
test('[#105] migration: existing install moves its tokens to the keychain, then off disk', async () => {
store.clear();
reload();
login('syt_old', 'mar_old'); // a pre-step-2 install
const kc = keychain();
const boot = await bootKeychainSession(kc.invoke);
assert.equal(boot.mode, 'keychain');
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old');
assert.equal(tokensOnDisk(), true, 'not stripped before the keychain is verified');
assert.equal((await mirror(kc.invoke)).state, 'ok');
assert.equal(tokensOnDisk(), false, 'tokens gone from localStorage');
assert.equal(blob().tokensIn, 'keychain');
assert.equal(store.get('cinny_access_token'), undefined);
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old', 'still signed in');
// Next launch: shell + keychain -> the same session.
reload();
const again = await bootKeychainSession(kc.invoke);
assert.equal(again.mode, 'keychain');
const s = sessions.getFallbackSession();
assert.equal(s?.accessToken, 'syt_old');
assert.equal(s?.refreshToken, 'mar_old');
assert.equal(s?.baseUrl, 'https://hs.example');
assert.ok((s?.expiresInMs ?? 0) > 0, 'expiry kept in the shell');
});
test('[#105] token refresh: written in full first, stripped once the keychain has it', async () => {
// continues from the previous state: keychain mode, shell on disk
const kc = keychain();
kc.set({
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_old',
refreshToken: 'mar_old',
});
login('syt_new', 'mar_new');
assert.equal(blob().accessToken, 'syt_new', 'durable on disk until the keychain confirms');
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_new');
await mirror(kc.invoke);
assert.equal(tokensOnDisk(), false);
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
});
test('[#105] a failing keychain write keeps the tokens in localStorage', async () => {
store.clear();
reload();
login('syt_keep', 'mar_keep');
const kc = keychain({ setThrows: true });
await bootKeychainSession(kc.invoke);
assert.equal((await mirror(kc.invoke)).state, 'error');
assert.equal(blob().accessToken, 'syt_keep', 'nothing stripped');
reload();
await bootKeychainSession(kc.invoke);
assert.equal(
sessions.getFallbackSession()?.accessToken,
'syt_keep',
'still signed in after a restart',
);
});
test('[#105] shell but no keychain entry: "missing", never a silent logout', async () => {
store.clear();
reload();
login('syt_x');
const kc = keychain();
await bootKeychainSession(kc.invoke);
await mirror(kc.invoke);
kc.set(null); // someone deleted the Credential Manager entry
reload();
const boot = await bootKeychainSession(kc.invoke);
assert.deepEqual(boot, { mode: 'missing', userId: '@alice:example.org' });
assert.equal(blob().tokensIn, 'keychain', 'shell kept for Retry');
});
test('[#105] shell but the keychain read fails: "error", shell kept', async () => {
// continues: shell on disk
reload();
const boot = await bootKeychainSession(keychain({ getThrows: true }).invoke);
assert.equal(boot.mode, 'error');
assert.equal(blob().tokensIn, 'keychain');
});
test('[#105] a shell for another device never pairs with the keychain', async () => {
store.clear();
reload();
login('syt_a');
const kc = keychain();
await bootKeychainSession(kc.invoke);
await mirror(kc.invoke);
kc.set({ userId: '@alice:example.org', deviceId: 'OTHER', accessToken: 'syt_b' });
reload();
assert.equal((await bootKeychainSession(kc.invoke)).mode, 'missing');
});
test('[#105] logout clears memory, disk and (via the mirror) the keychain', async () => {
store.clear();
reload();
login('syt_bye', 'mar_bye');
const kc = keychain();
await bootKeychainSession(kc.invoke);
await mirror(kc.invoke);
sessions.removeFallbackSession();
assert.equal(sessions.getFallbackSession(), undefined);
assert.equal(store.size, 0);
await mirror(kc.invoke);
assert.equal(kc.get(), null);
reload();
assert.deepEqual(await bootKeychainSession(kc.invoke), { mode: 'keychain', session: undefined });
});
+76
View File
@@ -0,0 +1,76 @@
import {
enterKeychainMode,
hasKeychainShell,
KeychainBoot,
KeychainSessionTokens,
} from './sessions';
import { KEYCHAIN_TIMEOUT_MS, KeychainInvoke } from './keychainMirror';
/**
* [Gitea #105] Desktop, step 2: at boot, before the app renders, read the login
* tokens from the OS keychain into memory (see sessions.ts). Returns:
* - `off`: not the desktop app, or no keychain here (Linux, older builds) —
* localStorage stays authoritative, exactly as before;
* - a KeychainBoot from enterKeychainMode: `keychain` (go on), or `missing`
* (localStorage says the tokens are in the keychain but they can't be read;
* the caller shows Retry / Sign in again and must NOT start the mirror,
* which would clear the keychain entry it couldn't read).
*/
export type KeychainBootResult = { mode: 'off' } | KeychainBoot | { mode: 'error'; error: string };
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
const asTokens = (v: unknown): KeychainSessionTokens | null => {
if (!v || typeof v !== 'object') return null;
const t = v as Partial<KeychainSessionTokens>;
if (typeof t.userId !== 'string' || typeof t.deviceId !== 'string') return null;
if (typeof t.accessToken !== 'string') return null;
return {
userId: t.userId,
deviceId: t.deviceId,
accessToken: t.accessToken,
...(typeof t.refreshToken === 'string' ? { refreshToken: t.refreshToken } : {}),
};
};
export async function bootKeychainSession(
invoke: KeychainInvoke | undefined,
timeoutMs = KEYCHAIN_TIMEOUT_MS,
): Promise<KeychainBootResult> {
if (!invoke) return { mode: 'off' };
let supported: unknown;
try {
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
} catch {
supported = false;
}
if (supported !== true) {
// A shell without a keychain to read from can only mean the store went
// away (e.g. a downgrade of the desktop build): say so, don't log out.
return hasKeychainShell()
? { mode: 'error', error: 'keychain not available' }
: { mode: 'off' };
}
let stored: unknown;
try {
stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
} catch (e) {
if (hasKeychainShell())
return { mode: 'error', error: (e instanceof Error ? e.message : String(e)).slice(0, 200) };
return { mode: 'off' };
}
return enterKeychainMode(asTokens(stored));
}
+139
View File
@@ -51,6 +51,21 @@ const OIDC_KEYS = {
// `_v1` suffix.
const SESSION_BLOB_KEY = 'cinny_session_v1';
// [Gitea #105] Desktop, step 2: the login tokens live in the OS keychain.
// localStorage then holds a SHELL blob (everything but the tokens, marked
// `tokensIn: 'keychain'`), and the full session is kept in memory, filled at
// boot (bootKeychainSession) before the app renders, so every reader of
// getFallbackSession stays synchronous. A new session is written to
// localStorage in full first; the tokens are stripped only once the keychain
// write was read back (stripTokensFromStorage), so at every instant one durable
// store holds the newest tokens (a token refresh invalidates the old ones).
let keychainMode = false;
let memorySession: PersistedSession | undefined;
type ShellSession = Omit<PersistedSession, 'accessToken' | 'refreshToken'> & {
tokensIn: 'keychain';
};
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
@@ -266,6 +281,7 @@ export function setFallbackSession(
extra?: FallbackSessionExtra,
) {
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
if (keychainMode) memorySession = persisted;
// ONE setItem — the blob can never be observed half-written.
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
// Dual-write the legacy keys (removal of this half is a future release).
@@ -276,6 +292,7 @@ export function setFallbackSession(
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
// or legacy-fallback) can resurrect a logged-out session.
export const removeFallbackSession = () => {
memorySession = undefined;
localStorage.removeItem(SESSION_BLOB_KEY);
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
@@ -286,7 +303,23 @@ export const removeFallbackSession = () => {
// corrupt/partial we transparently assemble from the legacy keys (migration);
// the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction.
// The stored session with its tokens, choosing blob vs legacy as below.
const readStoredPersisted = (): PersistedSession | undefined => {
const blob = readSessionBlob();
const legacy = readLegacyKeys();
if (
blob &&
legacy &&
typeof legacy.expiresAt === 'number' &&
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
) {
return legacy;
}
return blob ?? legacy;
};
export const getFallbackSession = (): Session | undefined => {
if (keychainMode) return memorySession ? sessionFromPersisted(memorySession) : undefined;
const blob = readSessionBlob();
const legacy = readLegacyKeys();
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
@@ -305,6 +338,112 @@ export const getFallbackSession = (): Session | undefined => {
if (!persisted) return undefined;
return sessionFromPersisted(persisted);
};
// --- [Gitea #105] desktop keychain session -----------------------------------
export type KeychainSessionTokens = {
userId: string;
deviceId: string;
accessToken: string;
refreshToken?: string;
};
/** The shell blob written while the tokens are in the keychain, if any. */
const readShell = (): Omit<ShellSession, 'tokensIn'> | undefined => {
const raw = localStorage.getItem(SESSION_BLOB_KEY);
if (!raw) return undefined;
try {
const p = JSON.parse(raw) as Partial<ShellSession>;
if (
p?.tokensIn !== 'keychain' ||
typeof p.deviceId !== 'string' ||
typeof p.userId !== 'string' ||
typeof p.baseUrl !== 'string'
)
return undefined;
const { tokensIn: _drop, ...rest } = p as ShellSession;
return rest;
} catch {
return undefined;
}
};
/** A session in localStorage that still carries its tokens (blob or legacy). */
export const readStoredSessionWithTokens = (): Session | undefined => {
const persisted = readStoredPersisted();
return persisted ? sessionFromPersisted(persisted) : undefined;
};
/** Whether localStorage holds a token-less shell (tokens are in the keychain). */
export const hasKeychainShell = (): boolean => readShell() !== undefined;
export type KeychainBoot =
/** Use the keychain: memory holds `session` (or none = signed out). */
| { mode: 'keychain'; session: Session | undefined }
/** A shell says the tokens are in the keychain, but they can't be read. */
| { mode: 'missing'; userId: string };
/**
* Decide the boot session from the keychain's tokens and localStorage, and
* switch this tab to keychain mode. `tokens` is what the keychain returned
* (null = no entry). Pure apart from reading localStorage and setting the
* in-memory state.
*/
export const enterKeychainMode = (tokens: KeychainSessionTokens | null): KeychainBoot => {
const withTokens = readStoredPersisted();
const shell = readShell();
keychainMode = true;
if (withTokens) {
// Pre-step-2 install, or a write not yet confirmed: localStorage has the
// full session and stays authoritative until the mirror verifies it.
memorySession = withTokens;
return { mode: 'keychain', session: sessionFromPersisted(withTokens) };
}
if (shell) {
if (tokens && tokens.userId === shell.userId && tokens.deviceId === shell.deviceId) {
memorySession = {
...shell,
accessToken: tokens.accessToken,
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
};
return { mode: 'keychain', session: sessionFromPersisted(memorySession) };
}
// Never treat this as a logout: the shell stays, the caller offers Retry.
memorySession = undefined;
return { mode: 'missing', userId: shell.userId };
}
memorySession = undefined;
return { mode: 'keychain', session: undefined };
};
/** Leave keychain mode (the keychain failed): localStorage is authoritative again. */
export const leaveKeychainMode = (): void => {
keychainMode = false;
memorySession = undefined;
};
/**
* The keychain now verifiably holds `tokens`: if they're still the current
* session's, drop the tokens from localStorage (shell blob, no legacy keys).
*/
export const stripTokensFromStorage = (tokens: KeychainSessionTokens): boolean => {
const m = memorySession;
if (
!keychainMode ||
!m ||
m.userId !== tokens.userId ||
m.deviceId !== tokens.deviceId ||
m.accessToken !== tokens.accessToken ||
(m.refreshToken ?? undefined) !== (tokens.refreshToken ?? undefined)
)
return false;
const { accessToken: _a, refreshToken: _r, ...rest } = m;
const shell: ShellSession = { ...rest, tokensIn: 'keychain' };
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(shell));
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
return true;
};
/**
* End of migration code for old session
*/
+57 -39
View File
@@ -18,15 +18,54 @@ import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
import { startKeychainMirror } from './app/state/keychainMirror';
import { bootKeychainSession, KeychainBootResult } from './app/state/keychainSession';
import { KeychainUnavailable } from './app/pages/KeychainUnavailable';
import { tauriInvoke } from './app/hooks/useTauri';
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
document.body.classList.add(configClass, varsClass);
// [Gitea #105] Desktop: keep a copy of the login tokens in the OS keychain
// (step 1: mirror only; the session is still read from localStorage).
startKeychainMirror(tauriInvoke());
// [Gitea #105] Desktop: the login tokens live in the OS keychain. Read them
// into memory BEFORE anything reads the session (step 2), then keep the
// keychain in line with every login / refresh / logout. If the keychain should
// hold them but can't be read, show Retry instead of the app, and don't start
// the mirror: it would clear the entry it couldn't read.
// Reload once if a lazy-loaded chunk is missing (stale deployment)
window.addEventListener('vite:preloadError', () => {
if (!sessionStorage.getItem('chunk-reload-attempted')) {
sessionStorage.setItem('chunk-reload-attempted', '1');
window.location.reload();
}
});
// Clear the reload flag after a successful load so future deploys can still trigger a reload
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload-attempted'));
// Filter out known-benign, high-volume matrix-js-sdk console warnings that we
// can't fix client-side and that would otherwise flood the console:
// - "Adding default global …": the SDK patches MSC3786/MSC3914 push rules on
// every login (one warn each) until Synapse ships them as server defaults.
// - "EventTimelineSet…": the SDK loudly warns whenever a decrypted event
// references a thread/room the current timeline set doesn't hold, then
// discards it harmlessly. This fires constantly in E2EE rooms with threads.
// - "Decrypted event … is not in room …": same family — a late decryption for
// an event the room's timeline no longer tracks; ignored by the SDK.
// These are informational SDK bookkeeping, not errors; real warnings still log.
{
const suppressedPrefixes = ['Adding default global ', 'EventTimelineSet'];
const _warn = console.warn.bind(console);
console.warn = (...args: unknown[]) => {
const first = args[0];
if (typeof first === 'string') {
if (suppressedPrefixes.some((p) => first.startsWith(p))) return;
if (first.startsWith('Decrypted event ') && first.includes('is not in room')) return;
}
_warn(...args);
};
}
// Everything that reads the session runs once the keychain step decided it.
const startSessionDependent = () => {
// Register Service Worker
// Service workers only register on http(s) pages. The desktop app loads from
// `tauri://localhost` in debug builds (and on any platform where the localhost
@@ -34,7 +73,8 @@ startKeychainMirror(tauriInvoke());
// unhandled "must be called with a script URL whose protocol is either HTTP or
// HTTPS". Skip it there; the app works without a SW, only authenticated media
// falls back to the client's own fetch.
const swProtocolOk = window.location.protocol === 'https:' || window.location.protocol === 'http:';
const swProtocolOk =
window.location.protocol === 'https:' || window.location.protocol === 'http:';
if ('serviceWorker' in navigator && swProtocolOk) {
const swUrl =
import.meta.env.MODE === 'production'
@@ -87,41 +127,9 @@ if (navigator.storage?.persist && getFallbackSession()) {
.then((already) => (already ? undefined : navigator.storage.persist()))
.catch(() => undefined);
}
// Reload once if a lazy-loaded chunk is missing (stale deployment)
window.addEventListener('vite:preloadError', () => {
if (!sessionStorage.getItem('chunk-reload-attempted')) {
sessionStorage.setItem('chunk-reload-attempted', '1');
window.location.reload();
}
});
// Clear the reload flag after a successful load so future deploys can still trigger a reload
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload-attempted'));
// Filter out known-benign, high-volume matrix-js-sdk console warnings that we
// can't fix client-side and that would otherwise flood the console:
// - "Adding default global …": the SDK patches MSC3786/MSC3914 push rules on
// every login (one warn each) until Synapse ships them as server defaults.
// - "EventTimelineSet…": the SDK loudly warns whenever a decrypted event
// references a thread/room the current timeline set doesn't hold, then
// discards it harmlessly. This fires constantly in E2EE rooms with threads.
// - "Decrypted event … is not in room …": same family — a late decryption for
// an event the room's timeline no longer tracks; ignored by the SDK.
// These are informational SDK bookkeeping, not errors; real warnings still log.
{
const suppressedPrefixes = ['Adding default global ', 'EventTimelineSet'];
const _warn = console.warn.bind(console);
console.warn = (...args: unknown[]) => {
const first = args[0];
if (typeof first === 'string') {
if (suppressedPrefixes.some((p) => first.startsWith(p))) return;
if (first.startsWith('Decrypted event ') && first.includes('is not in room')) return;
}
_warn(...args);
};
}
const mountApp = () => {
const mountApp = (keychainBoot: KeychainBootResult, keychainBlocked: boolean) => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
@@ -130,7 +138,17 @@ const mountApp = () => {
}
const root = createRoot(rootContainer);
root.render(<App />);
root.render(
keychainBlocked ? <KeychainUnavailable boot={keychainBoot} invoke={tauriInvoke()} /> : <App />,
);
};
mountApp();
bootKeychainSession(tauriInvoke())
// Whatever happens, the app mounts: an unexpected failure means "no keychain".
.catch((): KeychainBootResult => ({ mode: 'off' }))
.then((keychainBoot) => {
const keychainBlocked = keychainBoot.mode === 'missing' || keychainBoot.mode === 'error';
if (!keychainBlocked) startKeychainMirror(tauriInvoke());
startSessionDependent();
mountApp(keychainBoot, keychainBlocked);
});