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);
+ });