From 5f3c5d370ad438e37cd815c609e9967e12b303a5 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 23 Oct 2026 20:02:35 -0400 Subject: [PATCH] feat(desktop): keep the login tokens only in the OS keychain (#105 step 2) On the desktop app with a working keychain (Windows Credential Manager), the tokens leave localStorage: it keeps a token-less shell blob (tokensIn: keychain) and the full session lives in memory, read from the keychain at boot before anything reads the session, so getFallbackSession stays synchronous. Web, Linux and older desktop builds are unchanged. - A new session (login, token refresh) is written to localStorage in full first; the mirror verifies the keychain copy and only then strips the tokens, so one durable store always holds the newest tokens. - Existing installs migrate on first boot the same way; a failing keychain write keeps the tokens in localStorage. - If a shell is there but the keychain entry is missing or unreadable, the app shows Retry / Sign in again instead of logging out, and the mirror is not started (it would clear the entry). Fixes #105 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/pages/KeychainUnavailable.tsx | 69 ++++++++++ src/app/state/keychainMirror.ts | 27 ++-- src/app/state/keychainSession.test.ts | 183 ++++++++++++++++++++++++++ src/app/state/keychainSession.ts | 76 +++++++++++ src/app/state/sessions.ts | 139 +++++++++++++++++++ src/index.tsx | 152 +++++++++++---------- 6 files changed, 571 insertions(+), 75 deletions(-) create mode 100644 src/app/pages/KeychainUnavailable.tsx create mode 100644 src/app/state/keychainSession.test.ts create mode 100644 src/app/state/keychainSession.ts diff --git a/src/app/pages/KeychainUnavailable.tsx b/src/app/pages/KeychainUnavailable.tsx new file mode 100644 index 000000000..c47e3a75f --- /dev/null +++ b/src/app/pages/KeychainUnavailable.tsx @@ -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 ( + + Couldn't read your sign-in + + {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.'} + + {boot.mode === 'error' && ( + + {boot.error} + + )} + + + + + + ); +} diff --git a/src/app/state/keychainMirror.ts b/src/app/state/keychainMirror.ts index 2ccdc38cf..6ca36508f 100644 --- a/src/app/state/keychainMirror.ts +++ b/src/app/state/keychainMirror.ts @@ -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 = 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)); diff --git a/src/app/state/keychainSession.test.ts b/src/app/state/keychainSession.test.ts new file mode 100644 index 000000000..1cdfe1cdb --- /dev/null +++ b/src/app/state/keychainSession.test.ts @@ -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(); +(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) => Promise; +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 }); +}); diff --git a/src/app/state/keychainSession.ts b/src/app/state/keychainSession.ts new file mode 100644 index 000000000..a02003871 --- /dev/null +++ b/src/app/state/keychainSession.ts @@ -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 = (p: Promise, ms: number): Promise => + new Promise((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; + 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 { + 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)); +} diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index 2c00061c7..a0c187b48 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -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 & { + 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 | undefined => { + const raw = localStorage.getItem(SESSION_BLOB_KEY); + if (!raw) return undefined; + try { + const p = JSON.parse(raw) as Partial; + 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 */ diff --git a/src/index.tsx b/src/index.tsx index a2c853b44..e855f7f23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,75 +18,18 @@ 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()); - -// 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 -// plugin isn't used), where register() rejects: that surfaced in Sentry as an -// 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:'; -if ('serviceWorker' in navigator && swProtocolOk) { - const swUrl = - import.meta.env.MODE === 'production' - ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` - : `/dev-sw.js?dev-sw`; - - const sendSessionToSW = () => { - const session = getFallbackSession(); - pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId); - }; - - // The dev worker is an ES module (it imports workbox), so it must be - // registered as one — otherwise "script evaluation failed" and the dev - // client has no SW: authenticated media 401s and notification clicks - // never route. Production sw.js is a classic bundled script. - navigator.serviceWorker - .register(swUrl, import.meta.env.MODE === 'production' ? undefined : { type: 'module' }) - .then(sendSessionToSW) - .catch((err) => { - // e.g. a private window with SWs disabled; the app still works. - console.warn('Service worker registration failed', err); - }); - navigator.serviceWorker.ready.then(sendSessionToSW); - - navigator.serviceWorker.addEventListener('message', (ev) => { - const { type } = ev.data ?? {}; - - if (type === 'requestSession') { - sendSessionToSW(); - } - }); -} - -// Request persistent storage so the browser can't evict the IndexedDB -// rust-crypto store under storage pressure. Eviction (while the localStorage -// session/device-id survives) resurrects the device with a blank crypto store, -// which then re-uploads OTKs the server already holds → the "one time key -// already exists" upload storm and E2EE breakage. Only ask for sessions worth -// protecting (skip anonymous/landing visitors to avoid a needless Firefox -// prompt); check persisted() first so we don't re-prompt. Best-effort. -// [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another -// tab still held the DB), finish it now that no session exists. -if (!getFallbackSession()) { - cleanupSearchCacheIfSignedOut().catch(() => undefined); -} - -if (navigator.storage?.persist && getFallbackSession()) { - navigator.storage - .persisted() - .then((already) => (already ? undefined : navigator.storage.persist())) - .catch(() => undefined); -} +// [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', () => { @@ -121,7 +64,72 @@ window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload-at }; } -const mountApp = () => { +// 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 + // plugin isn't used), where register() rejects: that surfaced in Sentry as an + // 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:'; + if ('serviceWorker' in navigator && swProtocolOk) { + const swUrl = + import.meta.env.MODE === 'production' + ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` + : `/dev-sw.js?dev-sw`; + + const sendSessionToSW = () => { + const session = getFallbackSession(); + pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId); + }; + + // The dev worker is an ES module (it imports workbox), so it must be + // registered as one — otherwise "script evaluation failed" and the dev + // client has no SW: authenticated media 401s and notification clicks + // never route. Production sw.js is a classic bundled script. + navigator.serviceWorker + .register(swUrl, import.meta.env.MODE === 'production' ? undefined : { type: 'module' }) + .then(sendSessionToSW) + .catch((err) => { + // e.g. a private window with SWs disabled; the app still works. + console.warn('Service worker registration failed', err); + }); + navigator.serviceWorker.ready.then(sendSessionToSW); + + navigator.serviceWorker.addEventListener('message', (ev) => { + const { type } = ev.data ?? {}; + + if (type === 'requestSession') { + sendSessionToSW(); + } + }); + } + + // Request persistent storage so the browser can't evict the IndexedDB + // rust-crypto store under storage pressure. Eviction (while the localStorage + // session/device-id survives) resurrects the device with a blank crypto store, + // which then re-uploads OTKs the server already holds → the "one time key + // already exists" upload storm and E2EE breakage. Only ask for sessions worth + // protecting (skip anonymous/landing visitors to avoid a needless Firefox + // prompt); check persisted() first so we don't re-prompt. Best-effort. + // [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another + // tab still held the DB), finish it now that no session exists. + if (!getFallbackSession()) { + cleanupSearchCacheIfSignedOut().catch(() => undefined); + } + + if (navigator.storage?.persist && getFallbackSession()) { + navigator.storage + .persisted() + .then((already) => (already ? undefined : navigator.storage.persist())) + .catch(() => undefined); + } +}; + +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(); + root.render( + keychainBlocked ? : , + ); }; -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); + }); -- 2.47.3