Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 70206d6033 chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m50s
Picks up three releases of MatrixRTC / delayed-event fixes and
matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0.

SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so
the native-OAuth sign-in is ported:
- login discovers the auth server via /auth_metadata, registers with
  OAuth2.registerClient, and keeps the PKCE verifier + device in its own
  sessionStorage entry keyed by `state` (single use; CSRF check);
- refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16
  cross-tab protection is kept by a narrow guard on refresh-token grants
  (the SDK calls the global fetch for OAuth, not fetchFn), which
  serialises them with a Web Lock and adopts a sibling tab's tokens;
- logout and forced logout revoke via mx.logout() (the SDK revokes at
  the auth server for OAuth sessions);
- the callback page parsed its params on every render, re-running the
  single-use code exchange in a loop; parsed once now.

Also: notification_type is deprecated in MSC4075 (ring in DMs, notify
elsewhere when omitted), _onRTCSessionMemberUpdate ->
ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the
7.8 MB crypto wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 00:32:13 -04:00
jared bfed37bf50 Merge pull request 'feat(e2ee): tell the user when their device's key uploads are stuck (#201 KE-1)' (#446) from feat/key-upload-recovery into lotus
CI / Build & Quality Checks (push) Successful in 3m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
2026-10-24 00:11:28 -04:00
Lotus CIandClaude Opus 5.5 c2c43eda99 feat(e2ee): tell the user when their device's key uploads are stuck (#201 KE-1)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m41s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m54s
When the server keeps rejecting this device's /keys/upload with 400 "One
time key ... already exists", the device's encryption store has diverged
and the SDK retries forever (matrix-rust-sdk#5200, still open). Synapse
saw 474k of these from May 30 to Jul 8 2026. The fix is a fresh login, so:

- keyUploadWatch observes /keys/upload responses through the client's
  fetchFn (the response the SDK gets is untouched; a clone is read).
  Three conflicts within 15 min = stuck; any successful upload clears it.
- KeyUploadBanner (critical strip, same slot as the sync/clock banners):
  "Encryption on this device is out of sync" with Details and
  "Fix: sign out…", which opens the normal logout dialog (it already
  warns when the device isn't verified / backed up).

Server side, Grafana now alerts on the same signal (E2EE Key Upload
Conflicts) and on delayed-event 5xx (Call Delayed-Event Errors).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-23 23:51:15 -04:00
jared 45c3b16694 Merge pull request 'feat(desktop): keep the login tokens only in the OS keychain (#105 step 2)' (#445) from feat/keychain-session into lotus
CI / Build & Quality Checks (push) Successful in 3m13s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 16m28s
2026-10-23 20:59:25 -04:00
Lotus CIandClaude Opus 5.5 5f3c5d370a feat(desktop): keep the login tokens only in the OS keychain (#105 step 2)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m30s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-23 20:02:35 -04:00
jared 4120ccdbcc Merge pull request 'feat(embeds): Vimeo live events play inline (#200)' (#444) from feat/vimeo-events into lotus
CI / Build & Quality Checks (push) Successful in 4m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 16s
CI / Playwright smoke (e2e) (push) Successful in 21m22s
2026-10-21 00:14:21 -04:00
Lotus CIandClaude Opus 5.5 ed5bf997b4 feat(embeds): Vimeo live events play inline (#200)
CI / Build & Quality Checks (pull_request) Successful in 4m38s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m27s
vimeo.com/event/{id} (and /event/{id}/{hash} for unlisted events) now get
the same click-to-play card as Vimeo videos, embedding Vimeo's live-event
player (vimeo.com/event/{id}/embed[/{hash}], dnt=1). The issue assumed
this needed vimeo.com in the CSP, but frame-src is already https: on web
and desktop.

Refs #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 23:31:13 -04:00
jared 878a34712a Merge pull request 'feat(widgets): keep a widget on screen while you navigate away (#205)' (#443) from feat/widget-always-on-screen into lotus
CI / Build & Quality Checks (push) Successful in 3m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 42s
CI / Trigger Desktop Build (push) Successful in 18s
CI / Playwright smoke (e2e) (push) Successful in 19m38s
2026-10-20 22:21:48 -04:00
jared 62672787a0 Merge pull request 'feat(call): Details card after a call, with who/why/numbers/activity (#253)' (#442) from feat/call-summary-details into lotus
CI / Build & Quality Checks (push) Canceled after 18s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-20 21:37:26 -04:00
Lotus CIandClaude Opus 5.5 ac1a1c9a48 chore(deps): pin @lotusguild/element-call-embedded 0.25.0-lotus.29 (#253)
CI / Build & Quality Checks (pull_request) Successful in 3m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m13s
lotus.29 adds the call summary `details` (element-call#60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:33:13 -04:00
Lotus CIandClaude Opus 5.5 8645a55769 feat(call): Details card for the call-ended toast (#253)
The call-ended toast gets a Details button (and stays 10 s) that opens a
card with who was in the call and the peak, connection verdict and
reconnects, how long your / each person's / everyone's connection was
poor (likely the server when everyone), RTT, packet loss and jitter, and
your talk, mute, deafen and screenshare time, in technical wording.
Deafened time is tracked by CallEmbed; the rest comes from the fork's new
io.lotus.call_summary `details` (lotus.29). Settings → Calls gets
"Summary After Calls" (on by default).

Fixes #253

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:33:07 -04:00
41 changed files with 2066 additions and 357 deletions
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #201 KE-1] When the server keeps rejecting this device's key uploads
// with "One time key … already exists", the app says so and offers the fix.
const CONFLICT = {
errcode: 'M_UNKNOWN',
error: 'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {}; new key: {}',
};
test.describe('key upload recovery (#201)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('a diverged device sees the banner, and Fix opens the logout dialog', async ({ page }) => {
test.setTimeout(240_000);
const user = await ensureUser(uniq('kup'));
let rejected = 0;
await page.route(/\/_matrix\/client\/v3\/keys\/upload/, async (route) => {
rejected += 1;
await route.fulfill({ status: 400, json: CONFLICT });
});
await loginUI(page, user);
const banner = page
.getByRole('alert')
.filter({ hasText: /Encryption on this device is out of sync/ });
await expect(banner).toBeVisible({ timeout: 200_000 });
console.log(`key uploads rejected: ${rejected}`);
expect(rejected).toBeGreaterThanOrEqual(3);
await banner.getByRole('button', { name: 'Details' }).click();
await expect(banner).toContainText('Settings → Devices');
await expect(banner).toContainText('already exists');
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
await expect(page.getByRole('dialog').getByText('Logout').first()).toBeVisible();
});
test('a healthy device never sees it', async ({ page }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('kok'));
await loginUI(page, user);
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
await page.waitForTimeout(5000);
await expect(page.getByText(/Encryption on this device is out of sync/)).toHaveCount(0);
});
});
+234
View File
@@ -0,0 +1,234 @@
import { createHash } from 'node:crypto';
import { test, expect, Page, Route, BrowserContext } from '@playwright/test';
import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs';
// Native OAuth sign-in on matrix-js-sdk 44 (Gitea #201 SDK upgrade): login,
// refresh on an expired token, the #16 two-tab refresh race, and logout
// revocation. The homeserver is the real local Synapse; the auth server is
// faked here and hands out REAL Synapse tokens for the device the client asked
// for, so sync works after every step. "Expiry" is simulated by answering
// M_UNKNOWN_TOKEN for a token we mark expired.
const AUTH = 'https://auth.fake.test';
type Fake = {
registered: number;
tokenCalls: { grant: string; refresh?: string }[];
revoked: string[];
expired: Set<string>;
issuedAccess: string[];
};
const b64url = (buf: Buffer) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function synapseToken(localpart: string, deviceId: string): Promise<string> {
const res = await api<{ access_token: string }>('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: localpart },
password: PASSWORD,
device_id: deviceId,
});
return res.access_token;
}
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
const fake: Fake = {
registered: 0,
tokenCalls: [],
revoked: [],
expired: new Set(),
issuedAccess: [],
};
const codes = new Map<string, { challenge: string; deviceId: string }>();
const refreshes = new Map<string, string>(); // live refresh token -> deviceId (rotating)
let n = 0;
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
const issue = async (deviceId: string) => {
n += 1;
const access = await synapseToken(localpart, deviceId);
const refresh = `r${n}`;
refreshes.set(refresh, deviceId);
fake.issuedAccess.push(access);
return { token_type: 'Bearer', access_token: access, refresh_token: refresh, expires_in: 300 };
};
// Homeserver: advertise the issuer, serve auth metadata, and "expire" tokens.
await context.route(`${HS}/.well-known/matrix/client`, (route) =>
route.fulfill({
headers: cors,
json: { 'm.homeserver': { base_url: HS }, 'm.authentication': { issuer: `${AUTH}/` } },
}),
);
await context.route(`${HS}/_matrix/client/v1/auth_metadata`, (route) =>
route.fulfill({
headers: cors,
json: {
issuer: `${AUTH}/`,
authorization_endpoint: `${AUTH}/authorize`,
token_endpoint: `${AUTH}/token`,
registration_endpoint: `${AUTH}/register`,
revocation_endpoint: `${AUTH}/revoke`,
response_types_supported: ['code'],
response_modes_supported: ['query', 'fragment'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
},
}),
);
await context.route(`${HS}/_matrix/**`, async (route: Route) => {
const auth = route.request().headers().authorization ?? '';
const token = auth.replace(/^Bearer /, '');
if (token && fake.expired.has(token)) {
await route.fulfill({
status: 401,
headers: cors,
json: { errcode: 'M_UNKNOWN_TOKEN', error: 'Token expired', soft_logout: true },
});
return;
}
await route.fallback();
});
// Auth server.
await context.route(`${AUTH}/**`, async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
if (req.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: cors });
return;
}
if (url.pathname === '/register') {
fake.registered += 1;
await route.fulfill({ headers: cors, json: { client_id: 'fake-client' } });
return;
}
if (url.pathname === '/authorize') {
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
url.searchParams.get('scope') ?? '',
)?.[1];
expect(url.searchParams.get('response_mode')).toBe('query');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
const code = `code-${Date.now()}`;
codes.set(code, { challenge: url.searchParams.get('code_challenge')!, deviceId: deviceId! });
const back = new URL(url.searchParams.get('redirect_uri')!);
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state')!);
await route.fulfill({ status: 302, headers: { location: back.href } });
return;
}
const form = new URLSearchParams(req.postData() ?? '');
if (url.pathname === '/token') {
const grant = form.get('grant_type') ?? '';
fake.tokenCalls.push({ grant, refresh: form.get('refresh_token') ?? undefined });
if (grant === 'authorization_code') {
const pending = codes.get(form.get('code') ?? '');
const verifier = form.get('code_verifier') ?? '';
if (
!pending ||
b64url(createHash('sha256').update(verifier).digest()) !== pending.challenge
) {
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
return;
}
codes.delete(form.get('code')!);
await route.fulfill({ headers: cors, json: await issue(pending.deviceId) });
return;
}
if (grant === 'refresh_token') {
const deviceId = refreshes.get(form.get('refresh_token') ?? '');
if (!deviceId) {
// rotating refresh tokens: a used one is dead
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
return;
}
refreshes.delete(form.get('refresh_token')!);
await route.fulfill({ headers: cors, json: await issue(deviceId) });
return;
}
}
if (url.pathname === '/revoke') {
fake.revoked.push(form.get('token') ?? '');
await route.fulfill({ status: 200, headers: cors, body: '' });
return;
}
await route.fulfill({ status: 404, headers: cors, body: '' });
});
return fake;
}
const storedSession = (page: Page) =>
page.evaluate(() => JSON.parse(localStorage.getItem('cinny_session_v1') ?? 'null'));
async function oauthLogin(page: Page) {
await page.goto(`/login/${enc(HS)}/`);
await page.getByRole('button', { name: /Continue with/ }).click();
await page.waitForURL(/\/home/, { timeout: 60_000 });
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
}
test.describe('OAuth sign-in on SDK 44', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('login, refresh on expiry, two-tab race, logout revokes', async ({ context }) => {
test.setTimeout(240_000);
const user = await ensureUser(uniq('oa'));
const fake = await fakeOAuth(context, user.localpart);
const page = await context.newPage();
// 1. Login: registration, PKCE-checked code exchange, session persisted.
await oauthLogin(page);
expect(fake.registered).toBe(1);
expect(fake.tokenCalls.map((c) => c.grant)).toEqual(['authorization_code']);
let s = await storedSession(page);
expect(s.userId).toBe(user.userId);
expect(s.refreshToken).toBe('r1');
expect(s.oidc.clientId).toBe('fake-client');
const t1 = s.accessToken;
// 2. Second tab on the same session.
const page2 = await context.newPage();
await page2.goto('/home/');
await page2.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
// 3. Token "expires": both tabs get M_UNKNOWN_TOKEN on their next request
// and refresh at once. Exactly one refresh may reach the auth server
// (r1 is single-use); the other tab must adopt its tokens.
// Sync long-polls continuously, so both tabs hit it within ~30 s.
fake.expired.add(t1);
await expect
.poll(async () => (await storedSession(page)).accessToken, { timeout: 90_000 })
.not.toBe(t1);
await page.waitForTimeout(5000);
const refreshCalls = fake.tokenCalls.filter((c) => c.grant === 'refresh_token');
expect(refreshCalls.map((c) => c.refresh)).toEqual(['r1']);
s = await storedSession(page);
expect(s.refreshToken).toBe('r2');
await Promise.all(
[page, page2].map((p) =>
expect(p.getByText(/Username or email|Sign-in failed|logged out/i)).toHaveCount(0),
),
);
// 4. Logout through the UI revokes both tokens at the auth server
// (the SDK does it for OAuth sessions instead of calling /logout).
await page2.close();
const latest = await storedSession(page);
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Logout' }).first().click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Logout', exact: true })
.last()
.click();
await expect(
page.getByLabel('Username or email').or(page.getByRole('button', { name: /Continue with/ })),
).toBeVisible({
timeout: 60_000,
});
expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
expect(await storedSession(page)).toBeNull();
});
});
+35 -42
View File
@@ -53,7 +53,7 @@
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
"matrix-js-sdk": "44.0.0",
"matrix-widget-api": "1.18.0",
"millify": "6.1.0",
"pdfjs-dist": "6.3.289",
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,15 +2695,15 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.28",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.28/element-call-embedded-0.25.0-lotus.28.tgz",
"integrity": "sha512-IFBHHSVw0CzrrLgx3oIIcJmBB4YMb8at4oVuSKXifZ7MmyHydxg9U3sTlZGgLUjMVrrAXj4gFzCksGHet4LSQw==",
"version": "0.25.0-lotus.29",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.29/element-call-embedded-0.25.0-lotus.29.tgz",
"integrity": "sha512-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"version": "18.9.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.9.0.tgz",
"integrity": "sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
@@ -5580,12 +5580,12 @@
"dev": true
},
"node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz",
"integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==",
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=22"
},
"funding": {
"type": "opencollective",
@@ -9152,15 +9152,6 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/katex": {
"version": "0.18.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
@@ -10037,21 +10028,19 @@
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "41.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"version": "44.0.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-44.0.0.tgz",
"integrity": "sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
"@babel/runtime": "^8.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.9.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^2.0.0",
"jwt-decode": "^4.0.0",
"content-type": "^3.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"matrix-widget-api": "^1.19.0",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6"
@@ -10060,6 +10049,22 @@
"node": ">=22.0.0"
}
},
"node_modules/matrix-js-sdk/node_modules/@babel/runtime": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.8.tgz",
"integrity": "sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==",
"license": "MIT"
},
"node_modules/matrix-js-sdk/node_modules/matrix-widget-api": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz",
"integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
}
},
"node_modules/matrix-widget-api": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
@@ -10405,18 +10410,6 @@
],
"license": "MIT"
},
"node_modules/oidc-client-ts": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+2 -2
View File
@@ -80,7 +80,7 @@
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
"matrix-js-sdk": "44.0.0",
"matrix-widget-api": "1.18.0",
"millify": "6.1.0",
"pdfjs-dist": "6.3.289",
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+6 -2
View File
@@ -571,7 +571,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
? content.sender_ts
: event.getTs();
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type;
// `notification_type` is deprecated in MSC4075 (matrix-js-sdk 43+): newer
// senders may omit it and leave the presentation to us. Then ring in a
// direct chat and notify quietly elsewhere, like Element Web.
const notificationType =
content.notification_type ?? (directs.has(room.roomId) ? 'ring' : 'notification');
const relation =
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
const refEventId = relation?.event_id;
@@ -653,7 +657,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
mx.getRooms().forEach((r) => {
mx.matrixRTC
.getActiveRoomSession(r)
?._onRTCSessionMemberUpdate()
?.ensureRecalculateSessionMembers()
.catch(() => undefined);
});
};
+4 -13
View File
@@ -1,5 +1,5 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { Capabilities, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
@@ -28,23 +28,14 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
// getAuthMetadata validates (and rejects when the server has no native
// OAuth, which is normal for servers using traditional SSO).
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
// Only validate if the server returned metadata — a rejected promise means no native
// Matrix OIDC (MSC3861/MAS), which is normal for servers using traditional SSO.
if (authMetadata !== undefined) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
}
return {
capabilities,
mediaConfig,
authMetadata: validatedAuthMetadata,
authMetadata,
};
}, [mx]),
);
@@ -0,0 +1,84 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { useAtom } from 'jotai';
import { callSummaryViewAtom } from '../../state/callSummary';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard';
/**
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
* shows who was in the call, connection quality and whose side it was on,
* network numbers and your own activity (technical wording).
*/
export function CallSummaryDialog() {
const [view, setView] = useAtom(callSummaryViewAtom);
const modalStyle = useModalStyle(400);
if (!view) return null;
const close = () => setView(undefined);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="call-summary-title"
style={modalStyle}
>
<Header
variant="Surface"
size="500"
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Phone} size="100" />
<Text id="call-summary-title" size="H4" truncate>
Call summary · {view.roomName}
</Text>
</Box>
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{view.sections.map((section) => (
<Box key={section.title} direction="Column" gap="100">
<Text size="L400">{section.title}</Text>
{section.lines.map((line) => (
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
{line}
</Text>
))}
</Box>
))}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -1749,6 +1749,7 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [callSummaryToast, setCallSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
@@ -2062,6 +2063,13 @@ function Calls() {
/>
}
/>
<SettingTile
title="Summary After Calls"
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
after={
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
}
/>
{hapticsSupported() && (
<SettingTile
title="Haptic Feedback"
+27 -5
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds';
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
useEffect(() => {
if (toast.sticky) return;
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
timerRef.current = setTimeout(
() => {
dismiss(toast.id);
},
// [Gitea #253] A toast with a button stays long enough to use it.
toast.actionLabel ? 10_000 : 4000,
);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id, toast.sticky]);
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
const handleCardClick = () => {
if (toast.onClick) {
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
</div>
<div style={bodyStyle}>{toast.body}</div>
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
{toast.actionLabel && toast.onAction && (
<div style={{ marginTop: config.space.S200 }}>
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={(e) => {
e.stopPropagation();
toast.onAction?.();
dismiss(toast.id);
}}
>
<Text size="B300">{toast.actionLabel}</Text>
</Button>
</div>
)}
</div>
);
}
+18 -2
View File
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useCallHangupEvent } from './useCallEmbed';
import { toastQueueAtom } from '../state/toast';
import { describeCallEnd } from '../utils/callSummary';
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
import { callSummaryViewAtom } from '../state/callSummary';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
// before Element Call's hangup echo; give a late one this long before toasting.
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
*/
export function useCallEndedToast(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
const setSummaryView = useSetAtom(callSummaryViewAtom);
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const fired = useRef(false);
useEffect(() => {
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
useCallHangupEvent(embed, () => {
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
fired.current = true;
if (!callSummaryToast) return;
const durationMs = Date.now() - embed.joinedAt;
const deafenedMs = embed.deafenedMs();
const roomName = embed.room.name ?? 'Voice call';
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
const toast = () =>
setToast({
id: `call-ended-${Date.now()}`,
displayName: 'Lotus Chat',
body: describeCallEnd(durationMs, embed.lastSummary),
roomName: embed.room.name ?? 'Voice call',
roomName,
roomId: embed.roomId,
// [Gitea #253] The full breakdown, snapshotted at hangup.
actionLabel: 'Details',
onAction: () =>
setSummaryView({
roomName,
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
}),
});
if (embed.lastSummary) toast();
else setTimeout(toast, SUMMARY_GRACE_MS);
+2
View File
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
@@ -248,6 +249,7 @@ function App() {
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
<CallSummaryDialog />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
+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>
);
}
+1 -5
View File
@@ -68,11 +68,7 @@ export function Login() {
<Text as="h1" size="H2" priority="400">
Login
</Text>
<OidcLogin
issuer={oidc.issuer}
homeserverBaseUrl={discovery['m.homeserver'].base_url}
serverName={server}
/>
<OidcLogin homeserverBaseUrl={discovery['m.homeserver'].base_url} serverName={server} />
<span data-spacing-node />
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
+2 -3
View File
@@ -4,7 +4,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { startOidcLogin } from '../oidc/oidcLoginUtil';
type OidcLoginProps = {
issuer: string;
homeserverBaseUrl: string;
serverName: string;
};
@@ -13,7 +12,7 @@ type OidcLoginProps = {
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
* single-button styling.
*/
export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) {
export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
useCallback(startOidcLogin, []),
);
@@ -35,7 +34,7 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
variant="Secondary"
fill="Soft"
outlined
onClick={() => startLogin(issuer, homeserverBaseUrl)}
onClick={() => startLogin(homeserverBaseUrl)}
disabled={ongoing}
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
>
+24 -17
View File
@@ -1,43 +1,45 @@
import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { createClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { setFallbackSession } from '../../../state/sessions';
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
import { getOidcCallbackUrl } from './oidcConfig';
import { completeOidcCodeGrant } from './oidcLoginUtil';
import {
invalidateCachedClient,
isStaleClientError,
parseOidcCallbackParams,
readStoredOidcIssuer,
takePendingOidcLogin,
} from './oidcState';
/**
* Exchange the authorization code for a Matrix session and persist it. The SDK
* (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage
* before the redirect and validates `state` (CSRF). `redirectUri` is the
* deterministic callback URL, recomputed here.
* Exchange the authorization code for a Matrix session and persist it. The
* pending sign-in saved before the redirect must exist for this `state`
* (CSRF check) and is consumed here.
*/
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } =
await completeAuthorizationCodeGrant(code, state);
const pending = takePendingOidcLogin(state);
if (!pending) throw new Error('No pending sign-in for this state.');
const tokenResponse = await completeOidcCodeGrant(code, pending);
// Derive Matrix user_id + device_id from the freshly-issued access token (the
// device is owned by the authentication service under MSC3861).
const tmp = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token });
const tmp = createClient({
baseUrl: pending.homeserverUrl,
accessToken: tokenResponse.access_token,
});
const { user_id: userId, device_id: deviceId } = await tmp.whoami();
if (!userId || !deviceId) {
throw new Error('Homeserver did not return a user/device for the OIDC session.');
}
setFallbackSession(tokenResponse.access_token, deviceId, userId, homeserverUrl, {
setFallbackSession(tokenResponse.access_token, deviceId, userId, pending.homeserverUrl, {
refreshToken: tokenResponse.refresh_token,
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
oidc: {
issuer: oidcClientSettings.issuer,
clientId: oidcClientSettings.clientId,
redirectUri: getOidcCallbackUrl(),
idTokenClaims: idTokenClaims as unknown as Record<string, unknown>,
issuer: pending.issuer,
clientId: pending.clientId,
redirectUri: pending.redirectUri,
},
});
};
@@ -71,14 +73,19 @@ function CallbackError({ message }: { message: string }) {
* router, since the redirect_uri must be a real non-hash path).
*/
export function OidcCallback() {
const params = parseOidcCallbackParams(window.location.search);
// Parsed once: a fresh object per render would re-run the effects below on
// every state change, and the code (and its pending sign-in) is single-use.
const params = useMemo(() => parseOidcCallbackParams(window.location.search), []);
const redeemed = useRef(false);
const [state, complete] = useAsyncCallback<void, unknown, [string, string]>(
useCallback(completeOidcLogin, []),
);
useEffect(() => {
if (params.kind === 'success') complete(params.code, params.state);
if (params.kind !== 'success' || redeemed.current) return;
redeemed.current = true;
complete(params.code, params.state);
}, [params, complete]);
useEffect(() => {
+12 -13
View File
@@ -1,4 +1,4 @@
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import type { OAuthRegistrationRequest } from 'matrix-js-sdk';
import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
@@ -17,22 +17,21 @@ export const getOidcCallbackUrl = (): string =>
withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH);
/**
* Client metadata sent during MSC2966 dynamic client registration.
* Client metadata sent during dynamic client registration.
*
* `registerOidcClient` drops any URI that doesn't share `clientUri` as a common
* base, so every URI here lives under our origin base.
* The SDK drops any URI that doesn't share `client_uri` as a common base, so
* every URI here lives under our origin base.
*/
export const getOidcClientMetadata = (): OidcRegistrationClientMetadata => {
export const getOidcClientMetadata = (): OAuthRegistrationRequest => {
// `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs.
const clientUri = getOriginBaseUrl();
return {
clientName: 'Lotus Chat',
clientUri,
logoUri: new URL(LotusLogo, window.location.origin).href,
applicationType: 'web',
contacts: ['support@lotusguild.org'],
tosUri: clientUri,
policyUri: clientUri,
redirectUris: [getOidcCallbackUrl()],
client_name: 'Lotus Chat',
client_uri: clientUri,
logo_uri: new URL(LotusLogo, window.location.origin).href,
application_type: 'web',
tos_uri: clientUri,
policy_uri: clientUri,
redirect_uris: [getOidcCallbackUrl()],
};
};
+50 -27
View File
@@ -1,57 +1,80 @@
import {
completeAuthorizationCodeGrant,
discoverAndValidateOIDCIssuerWellKnown,
generateOidcAuthorizationUrl,
OidcClientConfig,
registerOidcClient,
} from 'matrix-js-sdk';
import { createClient, OAuth2, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
import {
cacheClientId,
getCachedClientId,
invalidateCachedClient,
isStaleClientError,
PendingOidcLogin,
savePendingOidcLogin,
} from './oidcState';
export { completeAuthorizationCodeGrant };
/**
* The homeserver's OAuth 2.0 metadata (`GET /_matrix/client/v1/auth_metadata`).
* This is also how the SDK finds the auth server for token refresh and logout,
* so login uses the same source.
*/
export const discoverAuthMetadata = (homeserverBaseUrl: string): Promise<ValidatedAuthMetadata> =>
createClient({ baseUrl: homeserverBaseUrl }).getAuthMetadata();
export const getOrRegisterClientId = async (
config: OidcClientConfig,
issuer: string,
metadata: ValidatedAuthMetadata,
redirectUri: string,
): Promise<string> => {
const cached = getCachedClientId(issuer, redirectUri);
const cached = getCachedClientId(metadata.issuer, redirectUri);
if (cached) return cached;
const clientId = await registerOidcClient(config, getOidcClientMetadata());
cacheClientId(issuer, clientId, redirectUri);
const clientId = await OAuth2.registerClient(metadata, getOidcClientMetadata());
cacheClientId(metadata.issuer, clientId, redirectUri);
return clientId;
};
/**
* Begin the OIDC authorization-code flow: discover + validate the issuer,
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
* persists the transient code_verifier/state/nonce/homeserverUrl into
* sessionStorage for the callback), then redirect.
* Begin the OAuth authorization-code flow: discover the auth server through
* the homeserver, (re)register a client, remember the PKCE verifier + device
* under a fresh `state` (oidcState), then redirect. The callback reads the
* result from the query string (`response_mode=query`).
*/
export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string): Promise<void> => {
export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> => {
const redirectUri = getOidcCallbackUrl();
let issuer: string | undefined;
try {
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
const url = await generateOidcAuthorizationUrl({
metadata: config,
redirectUri,
const metadata = await discoverAuthMetadata(homeserverBaseUrl);
issuer = metadata.issuer;
const clientId = await getOrRegisterClientId(metadata, redirectUri);
const oauth = new OAuth2(metadata, { clientId });
const state = crypto.randomUUID();
const pending: PendingOidcLogin = {
issuer,
clientId,
deviceId: oauth.context.deviceId,
codeVerifier: oauth.context.codeVerifier,
homeserverUrl: homeserverBaseUrl,
nonce: crypto.randomUUID(),
});
window.location.assign(url);
redirectUri,
};
savePendingOidcLogin(state, pending);
window.location.assign(
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query'),
);
} catch (e) {
// #67 — drop the cached client so the next attempt re-registers, but ONLY
// when the failure says the client id itself was rejected. Network and
// discovery failures keep the cache, otherwise each retry would register
// yet another throwaway dynamic client at the provider.
if (isStaleClientError(e)) invalidateCachedClient(issuer);
if (issuer && isStaleClientError(e)) invalidateCachedClient(issuer);
throw e;
}
};
/**
* Exchange the authorization code using the pending sign-in saved before the
* redirect. The metadata is fetched again (it isn't stored).
*/
export const completeOidcCodeGrant = async (code: string, pending: PendingOidcLogin) => {
const metadata = await discoverAuthMetadata(pending.homeserverUrl);
const oauth = new OAuth2(metadata, {
clientId: pending.clientId,
deviceId: pending.deviceId,
codeVerifier: pending.codeVerifier,
});
return oauth.completeAuthorizationCodeGrant(code, pending.redirectUri);
};
+21 -11
View File
@@ -7,6 +7,8 @@ import {
parseOidcCallbackParams,
isStaleClientError,
readStoredOidcIssuer,
savePendingOidcLogin,
takePendingOidcLogin,
} from './oidcState';
const installStorage = (): Map<string, string> => {
@@ -131,21 +133,29 @@ test('isStaleClientError: also recognises the OidcCallback error-redirect shape
);
});
test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => {
const store = installSessionStorage();
const pending = {
issuer: 'https://issuer.example/',
clientId: 'client1',
deviceId: 'DEVICEABCD',
codeVerifier: 'v'.repeat(96),
homeserverUrl: 'https://hs.example',
redirectUri: 'https://app.example/oidc/callback',
};
test('pending sign-in: saved by state, read for the issuer, consumed once', () => {
installSessionStorage();
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
store.set(
'mx_oidc_xyz',
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
);
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
savePendingOidcLogin('xyz', pending);
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/');
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
assert.deepEqual(takePendingOidcLogin('xyz'), pending);
assert.equal(takePendingOidcLogin('xyz'), undefined, 'single use');
});
test('readStoredOidcIssuer tolerates corrupt or missing storage', () => {
test('pending sign-in tolerates corrupt or incomplete storage', () => {
const store = installSessionStorage();
store.set('mx_oidc_bad', '{ not json');
store.set('lotus_oidc_pending_bad', '{ not json');
assert.equal(readStoredOidcIssuer('bad'), undefined);
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
assert.equal(readStoredOidcIssuer('noauth'), undefined);
store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' }));
assert.equal(takePendingOidcLogin('partial'), undefined);
});
+50 -13
View File
@@ -97,23 +97,60 @@ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
};
/**
* #102 — the issuer for a pending sign-in isn't available on the callback's
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
* persists the pending sign-in's `SigninState` (including `authority` and
* `client_id`) into sessionStorage before redirecting, keyed by
* `mx_oidc_<state>` (WebStorageStateStore prefix `mx_oidc_`, keyed by the same
* `state` value the provider echoes back on redirect). Read it directly so a
* genuinely stale cached client id (`invalid_client`) can be evicted even when
* the callback errors before a token exchange is attempted.
* [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's
* OAuth2 class no longer persists anything). Saved in sessionStorage right
* before the redirect, keyed by the `state` we send, which the provider echoes
* back: a callback whose state has no entry is rejected (CSRF), and an entry is
* used once. Holds the PKCE code verifier, so it never goes to localStorage.
*/
export const readStoredOidcIssuer = (state: string): string | undefined => {
export type PendingOidcLogin = {
issuer: string;
clientId: string;
deviceId: string;
codeVerifier: string;
homeserverUrl: string;
redirectUri: string;
};
const PENDING_PREFIX = 'lotus_oidc_pending_';
const isPending = (v: unknown): v is PendingOidcLogin => {
if (!v || typeof v !== 'object') return false;
const p = v as Record<string, unknown>;
return ['issuer', 'clientId', 'deviceId', 'codeVerifier', 'homeserverUrl', 'redirectUri'].every(
(k) => typeof p[k] === 'string' && (p[k] as string).length > 0,
);
};
export const savePendingOidcLogin = (state: string, pending: PendingOidcLogin): void => {
sessionStorage.setItem(`${PENDING_PREFIX}${state}`, JSON.stringify(pending));
};
/** Read the pending sign-in for `state` without consuming it. */
export const readPendingOidcLogin = (state: string): PendingOidcLogin | undefined => {
try {
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`);
if (!raw) return undefined;
const { authority } = JSON.parse(raw) as { authority?: unknown };
return typeof authority === 'string' ? authority : undefined;
const parsed: unknown = JSON.parse(raw);
return isPending(parsed) ? parsed : undefined;
} catch {
return undefined;
}
};
/** Read and remove: an authorization code can only be redeemed once anyway. */
export const takePendingOidcLogin = (state: string): PendingOidcLogin | undefined => {
const pending = readPendingOidcLogin(state);
try {
sessionStorage.removeItem(`${PENDING_PREFIX}${state}`);
} catch {
// storage unavailable — nothing to clean up
}
return pending;
};
/**
* #102 — on the callback's error branch, the pending sign-in's issuer tells
* us whose cached client id to evict when the provider rejected it.
*/
export const readStoredOidcIssuer = (state: string): string | undefined =>
readPendingOidcLogin(state)?.issuer;
+12 -6
View File
@@ -46,6 +46,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { ClockSkewBanner } from './ClockSkewBanner';
import { KeyUploadBanner } from './KeyUploadBanner';
import {
ServerStatusBanner,
ServerStatusFeature,
@@ -54,7 +55,6 @@ import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
import { revokeOidcTokens } from '../../../client/oidcLogout';
import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery';
@@ -158,11 +158,11 @@ const useLogoutListener = (mx?: MatrixClient) => {
pushSessionToSW();
mx?.stopClient();
// Best-effort issuer revocation for OIDC sessions (the token is already
// server-revoked here, but revoke the refresh token too). Before we drop
// the stored session below.
const loggedOutSession = getFallbackSession();
if (loggedOutSession?.oidc) {
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
// server-revoked here, but revoke the refresh token too). For an OAuth
// session the SDK's logout() only revokes at the auth server (no /logout
// call). Before we drop the stored session below.
if (getFallbackSession()?.oidc) {
await mx?.logout().catch(() => undefined);
}
await mx?.clearStores();
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
@@ -260,6 +260,12 @@ export function ClientRoot({ children }: ClientRootProps) {
{mx && !syncError && <ServerStatusBanner />}
{mx && !syncError && <SyncStatus mx={mx} />}
{mx && !syncError && <ClockSkewBanner />}
{mx && !syncError && (
// The fix opens LogoutDialog, which reads the client from context.
<MatrixClientProvider value={mx}>
<KeyUploadBanner />
</MatrixClientProvider>
)}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
+104
View File
@@ -0,0 +1,104 @@
import React, { useState, useSyncExternalStore } from 'react';
import FocusTrap from 'focus-trap-react';
import { Box, Button, config, Line, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { getKeyUploadWatch, subscribeKeyUploadWatch } from '../../utils/keyUploadWatch';
import { LogoutDialog } from '../../components/LogoutDialog';
import { stopPropagation } from '../../utils/keyboard';
/**
* [Gitea #201 KE-1] "Encryption on this device is out of sync." Shown while
* the server keeps rejecting this device's key uploads (keyUploadWatch). The
* fix is a fresh login, so the action opens the normal logout dialog, which
* already warns if the device isn't verified / backed up. Hideable for this
* session only: the problem doesn't go away by itself.
*/
export function KeyUploadBanner() {
const { stuck, lastError } = useSyncExternalStore(subscribeKeyUploadWatch, getKeyUploadWatch);
const [hidden, setHidden] = useState(false);
const [details, setDetails] = useState(false);
const [logout, setLogout] = useState(false);
if (!stuck || hidden) return null;
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Critical' })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
alignItems="Center"
gap="100"
role="alert"
>
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
<Text size="L400" align="Center">
Encryption on this device is out of sync with the server. New encrypted messages and
calls may fail until you sign out and back in.
</Text>
<Button
size="300"
variant="Critical"
fill="Solid"
radii="300"
onClick={() => setLogout(true)}
>
<Text size="B300">Fix: sign out…</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={() => setDetails((v) => !v)}
aria-expanded={details}
>
<Text size="B300">Details</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
onClick={() => setHidden(true)}
>
<Text size="B300">Hide</Text>
</Button>
</Box>
{details && (
<Box direction="Column" gap="100" style={{ maxWidth: 640 }}>
<Text size="T200" align="Center">
The server already has encryption keys this device thinks it hasn&apos;t sent, so it
rejects every new upload and other people can&apos;t start fresh encrypted sessions
with you. This usually happens after the browser cleared this site&apos;s storage
while you stayed signed in. Before signing out, make sure this device is verified or
key backup is on (Settings → Devices), so your message history comes back when you
sign in again.
</Text>
{lastError && (
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
Server said: {lastError}
</Text>
)}
</Box>
)}
</Box>
<Line variant="Critical" size="300" />
{logout && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setLogout(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={() => setLogout(false)} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Box>
);
}
+27 -1
View File
@@ -26,13 +26,14 @@ import {
ElementMediaStateDetail,
ElementWidgetActions,
} from './types';
import { CallControl } from './CallControl';
import { CallControl, CallControlEvent } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -59,6 +60,8 @@ export interface LotusCallSummary {
reconnects: number;
poorMs: number;
verdict: 'good' | 'fair' | 'poor' | 'unknown';
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
details?: LotusCallSummaryDetails;
}
export class CallEmbed {
@@ -78,6 +81,25 @@ export class CallEmbed {
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
public lastSummary: LotusCallSummary | undefined;
// [Gitea #253] Time spent deafened (sound off), for the post-call Details.
private deafenedSince: number | undefined;
private deafenedTotal = 0;
public deafenedMs(now = Date.now()): number {
return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
}
private trackDeafened = (): void => {
if (!this.joined) return;
const deaf = !this.control.sound;
if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
if (!deaf && this.deafenedSince !== undefined) {
this.deafenedTotal += Date.now() - this.deafenedSince;
this.deafenedSince = undefined;
}
};
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
@@ -339,6 +361,8 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
this.initialState = controlState;
this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
// If the iframe document itself fails to load, fail fast.
iframe.onerror = () => {
this.settleLoad('iframe');
@@ -434,6 +458,7 @@ export class CallEmbed {
reconnects: data.reconnects ?? 0,
poorMs: data.poorMs ?? 0,
verdict: data.verdict ?? 'unknown',
details: parseCallSummaryDetails((data as { details?: unknown }).details),
};
}
}),
@@ -623,6 +648,7 @@ export class CallEmbed {
}
this.joined = true;
this.joinedAt = Date.now();
this.trackDeafened();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
+6
View File
@@ -0,0 +1,6 @@
import { atom } from 'jotai';
import type { CallSummarySection } from '../utils/callSummary';
/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
export const callSummaryViewAtom = atom<CallSummaryView | undefined>(undefined);
+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));
}
+142 -3
View File
@@ -5,9 +5,9 @@
// setLocalStorageItem,
// } from './utils/atomWithLocalStorage';
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept
// as a generic claims object so this core module stays decoupled from
// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims).
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Since
// matrix-js-sdk 43 only `clientId` is used (the SDK finds the auth server via
// /auth_metadata); `idTokenClaims` is kept for sessions saved by older builds.
export type OidcSessionMeta = {
issuer: string;
clientId: string;
@@ -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
*/
+3
View File
@@ -310,6 +310,8 @@ export interface Settings {
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
// [Gitea #253] The call-ended toast (with its Details card).
callSummaryToast: boolean;
soundboardVolume: number; // 0–100
seasonalThemeOverride: SeasonalThemeOverride;
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
callSummaryToast: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
+2
View File
@@ -13,6 +13,8 @@ export type ToastNotif = {
onClick?: () => void; // custom click handler; skips hash navigation when set
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
onAction?: () => void;
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
};
+100 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { describeCallDuration, describeCallEnd } from './callSummary';
import {
describeCallDuration,
describeCallEnd,
describeCallDetails,
parseCallSummaryDetails,
} from './callSummary';
describe('describeCallDuration', () => {
it('picks the unit', () => {
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
);
});
});
it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
const d = parseCallSummaryDetails({
participants: ['@bob:x', 7, '@carol:x'],
peakParticipants: 3,
quality: {
youPoorMs: 1000,
othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
everyonePoorMs: 0,
},
network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
});
assert.deepEqual(d, {
participants: ['@bob:x', '@carol:x'],
peakParticipants: 3,
quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
network: { rttMedianMs: 38, inboundLossPct: 0.5 },
activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
});
assert.equal(parseCallSummaryDetails(undefined), undefined);
assert.equal(parseCallSummaryDetails('details'), undefined);
});
it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
const names: Record<string, string> = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
const sections = describeCallDetails(
{
durationMs: 2_460_000,
reconnects: 2,
poorMs: 120_000,
verdict: 'fair',
details: {
participants: ['@bob:x', '@carol:x'],
peakParticipants: 3,
quality: {
youPoorMs: 120_000,
othersPoorMs: { '@carol:x': 30_000 },
everyonePoorMs: 10_000,
},
network: {
rttMedianMs: 38,
rttP95Ms: 310,
outboundLossPct: 4.1,
inboundLossPct: 0.5,
jitterMs: 6,
},
activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
},
},
2_460_000,
90_000,
(u) => names[u] ?? u,
);
assert.deepEqual(sections, [
{ title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
{
title: 'Connection',
lines: [
'Verdict: fair · 2 reconnects',
'Your connection poor/lost: 2 min',
'Carol poor/lost: 30 s',
'Everyone poor at once: 10 s (likely server or SFU side)',
],
},
{
title: 'Network',
lines: [
'RTT median 38 ms · p95 310 ms',
'Outbound packet loss 4.1%',
'Inbound packet loss 0.5%',
'Jitter 6 ms',
],
},
{
title: 'You',
lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
},
]);
});
it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
const sections = describeCallDetails(
{ durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
60_000,
0,
(u) => u,
);
assert.deepEqual(
sections.map((s) => s.title),
['Call', 'Connection'],
);
assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
});
+120
View File
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
}
return parts.join(' · ');
}
/** [Gitea #253] The fork's `details` in io.lotus.call_summary (lotus.29+). */
export type LotusCallSummaryDetails = {
participants: string[];
peakParticipants: number;
quality: { youPoorMs: number; othersPoorMs: Record<string, number>; everyonePoorMs: number };
network: {
rttMedianMs?: number;
rttP95Ms?: number;
outboundLossPct?: number;
inboundLossPct?: number;
jitterMs?: number;
};
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
};
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
const obj = (v: unknown): Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
/** Defensive: the frame is another origin; take only well-formed fields. */
export function parseCallSummaryDetails(raw: unknown): LotusCallSummaryDetails | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = obj(raw);
const quality = obj(r.quality);
const network = obj(r.network);
const activity = obj(r.activity);
const othersPoorMs: Record<string, number> = {};
Object.entries(obj(quality.othersPoorMs)).forEach(([user, ms]) => {
const n = num(ms);
if (n !== undefined && user.startsWith('@')) othersPoorMs[user] = n;
});
const net: LotusCallSummaryDetails['network'] = {};
(['rttMedianMs', 'rttP95Ms', 'outboundLossPct', 'inboundLossPct', 'jitterMs'] as const).forEach(
(k) => {
const n = num(network[k]);
if (n !== undefined) net[k] = n;
},
);
return {
participants: Array.isArray(r.participants)
? r.participants.filter((p): p is string => typeof p === 'string').slice(0, 200)
: [],
peakParticipants: num(r.peakParticipants) ?? 1,
quality: {
youPoorMs: num(quality.youPoorMs) ?? 0,
othersPoorMs,
everyonePoorMs: num(quality.everyonePoorMs) ?? 0,
},
network: net,
activity: {
speakingMs: num(activity.speakingMs) ?? 0,
mutedMs: num(activity.mutedMs) ?? 0,
screenshareMs: num(activity.screenshareMs) ?? 0,
},
};
}
export type CallSummarySection = { title: string; lines: string[] };
/**
* [Gitea #253] The Details card, in technical wording (the owner's choice):
* who was there, connection quality and whose side it was on, network
* numbers, and your own activity. `nameOf` turns a user id into a display name.
*/
export function describeCallDetails(
summary: LotusCallSummary | undefined,
durationMs: number,
deafenedMs: number,
nameOf: (userId: string) => string,
): CallSummarySection[] {
const sections: CallSummarySection[] = [];
const d = summary?.details;
const dur = describeCallDuration;
const people = d?.participants ?? [];
sections.push({
title: 'Call',
lines: [
`Duration ${dur(durationMs)}${d ? ` · peak ${d.peakParticipants} in the call` : ''}`,
people.length
? `With ${people.map(nameOf).join(', ')}`
: d
? 'Nobody else joined'
: 'Participants unknown',
],
});
if (summary) {
const quality: string[] = [
`Verdict: ${summary.verdict} · ${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`,
];
if (d) {
quality.push(`Your connection poor/lost: ${dur(d.quality.youPoorMs)}`);
Object.entries(d.quality.othersPoorMs)
.sort((a, b) => b[1] - a[1])
.forEach(([user, ms]) => quality.push(`${nameOf(user)} poor/lost: ${dur(ms)}`));
if (d.quality.everyonePoorMs > 0)
quality.push(
`Everyone poor at once: ${dur(d.quality.everyonePoorMs)} (likely server or SFU side)`,
);
} else quality.push(`Your connection poor/lost: ${dur(summary.poorMs)}`);
sections.push({ title: 'Connection', lines: quality });
}
if (d) {
const n = d.network;
const net: string[] = [];
if (n.rttMedianMs !== undefined)
net.push(`RTT median ${n.rttMedianMs} ms · p95 ${n.rttP95Ms ?? n.rttMedianMs} ms`);
if (n.outboundLossPct !== undefined) net.push(`Outbound packet loss ${n.outboundLossPct}%`);
if (n.inboundLossPct !== undefined) net.push(`Inbound packet loss ${n.inboundLossPct}%`);
if (n.jitterMs !== undefined) net.push(`Jitter ${n.jitterMs} ms`);
if (net.length) sections.push({ title: 'Network', lines: net });
const act = [
`Talking ${dur(d.activity.speakingMs)} · muted ${dur(d.activity.mutedMs)} · deafened ${dur(deafenedMs)}`,
];
if (d.activity.screenshareMs > 0) act.push(`Screensharing ${dur(d.activity.screenshareMs)}`);
sections.push({ title: 'You', lines: act });
}
return sections;
}
+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getKeyUploadWatch,
isKeyUploadUrl,
isOtkConflict,
recordKeyUpload,
resetKeyUploadWatch,
STUCK_AFTER,
subscribeKeyUploadWatch,
WINDOW_MS,
watchKeyUploads,
} from './keyUploadWatch';
const CONFLICT = JSON.stringify({
errcode: 'M_UNKNOWN',
error:
'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {...}; new key: {...}',
});
test('[#201] recognises the upload endpoint and the conflict body', () => {
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload'), true);
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload/'), true);
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/query'), false);
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/room_keys/keys'), false);
assert.equal(isOtkConflict(400, CONFLICT), true);
assert.equal(isOtkConflict(400, '{"errcode":"M_BAD_JSON"}'), false);
assert.equal(isOtkConflict(500, CONFLICT), false);
});
test('[#201] stuck only after repeated conflicts inside the window', () => {
resetKeyUploadWatch();
const t0 = 1_000_000;
for (let i = 0; i < STUCK_AFTER - 1; i += 1) recordKeyUpload(400, CONFLICT, t0 + i * 1000);
assert.equal(getKeyUploadWatch().stuck, false, 'a couple of races is not divergence');
recordKeyUpload(400, CONFLICT, t0 + 5000);
assert.equal(getKeyUploadWatch().stuck, true);
assert.match(getKeyUploadWatch().lastError ?? '', /already exists/);
});
test('[#201] old conflicts age out; other 400s are ignored', () => {
resetKeyUploadWatch();
recordKeyUpload(400, CONFLICT, 0);
recordKeyUpload(400, CONFLICT, 1000);
recordKeyUpload(400, CONFLICT, WINDOW_MS + 60_000);
assert.equal(getKeyUploadWatch().stuck, false);
recordKeyUpload(400, '{"errcode":"M_BAD_JSON"}', WINDOW_MS + 61_000);
assert.equal(getKeyUploadWatch().conflicts, 1);
});
test('[#201] a successful upload clears it and notifies', () => {
resetKeyUploadWatch();
let calls = 0;
const off = subscribeKeyUploadWatch(() => {
calls += 1;
});
for (let i = 0; i < STUCK_AFTER; i += 1) recordKeyUpload(400, CONFLICT, i);
assert.equal(getKeyUploadWatch().stuck, true);
recordKeyUpload(200, '');
assert.equal(getKeyUploadWatch().stuck, false);
assert.ok(calls >= 2);
off();
});
test('[#201] the fetch wrapper observes without changing the response', async () => {
resetKeyUploadWatch();
const fake: typeof fetch = async (input) =>
String(input).endsWith('/keys/upload')
? new Response(CONFLICT, { status: 400 })
: new Response('{}', { status: 200 });
const f = watchKeyUploads(fake);
const responses = await Promise.all(
Array.from({ length: STUCK_AFTER }, () =>
f('https://hs.example/_matrix/client/v3/keys/upload', { method: 'POST' }),
),
);
const bodies = await Promise.all(responses.map((res) => res.text()));
responses.forEach((res) => assert.equal(res.status, 400));
bodies.forEach((b) => assert.match(b, /already exists/, 'the SDK can still read the body'));
await new Promise((r) => {
setTimeout(r, 10);
});
assert.equal(getKeyUploadWatch().stuck, true);
await f('https://hs.example/_matrix/client/v3/sync');
assert.equal(getKeyUploadWatch().stuck, true, 'unrelated requests change nothing');
});
+102
View File
@@ -0,0 +1,102 @@
/**
* [Gitea #201 KE-1] Notice when this device's encryption store has diverged
* from the server: every POST /keys/upload answers 400 "One time key ...
* already exists", and the SDK retries forever (matrix-rust-sdk#5200, open),
* so new encrypted messages and calls quietly degrade. The server saw 474k of
* these between May 30 and Jul 8 2026. The only fix is a fresh login on that
* device, so we tell the user (KeyUploadBanner) instead of looping silently.
*
* Watched at the fetch layer (MatrixClient `fetchFn`), so it doesn't depend on
* SDK log wording. Pure state + a tiny subscribe API; no network I/O of its own.
*/
/** Conflicts needed before we call the device stuck. The SDK retries within
* seconds, so a real divergence crosses this quickly; a one-off race doesn't. */
export const STUCK_AFTER = 3;
/** Conflicts older than this don't count (a long-ago blip isn't "now"). */
export const WINDOW_MS = 15 * 60 * 1000;
export type KeyUploadWatchState = { stuck: boolean; conflicts: number; lastError?: string };
let conflictTimes: number[] = [];
let lastError: string | undefined;
let state: KeyUploadWatchState = { stuck: false, conflicts: 0 };
const listeners = new Set<() => void>();
const publish = (next: KeyUploadWatchState) => {
if (next.stuck === state.stuck && next.conflicts === state.conflicts) return;
state = next;
listeners.forEach((l) => l());
};
export const isKeyUploadUrl = (url: string): boolean => {
try {
return /\/_matrix\/client\/[^/]+\/keys\/upload\/?$/.test(new URL(url, 'http://x').pathname);
} catch {
return false;
}
};
/** The 400 body that marks a diverged one-time-key store. */
export const isOtkConflict = (status: number, body: string): boolean =>
status === 400 && /one time key .*already exists/i.test(body);
/** Feed one /keys/upload response. `now` is injectable for tests. */
export const recordKeyUpload = (status: number, body: string, now = Date.now()): void => {
if (status >= 200 && status < 300) {
// A successful upload means the stores agree again.
conflictTimes = [];
lastError = undefined;
publish({ stuck: false, conflicts: 0 });
return;
}
if (!isOtkConflict(status, body)) return;
conflictTimes = [...conflictTimes, now].filter((t) => now - t <= WINDOW_MS);
lastError = body.slice(0, 300);
publish({
stuck: conflictTimes.length >= STUCK_AFTER,
conflicts: conflictTimes.length,
lastError,
});
};
export const getKeyUploadWatch = (): KeyUploadWatchState => state;
export const subscribeKeyUploadWatch = (cb: () => void): (() => void) => {
listeners.add(cb);
return () => listeners.delete(cb);
};
/** Test/logout helper: forget everything (a new session starts clean). */
export const resetKeyUploadWatch = (): void => {
conflictTimes = [];
lastError = undefined;
publish({ stuck: false, conflicts: 0 });
};
const urlOf = (input: RequestInfo | URL): string =>
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
/**
* Wrap a fetch so /keys/upload responses are observed. The response the SDK
* gets is untouched; a clone is read in the background, never awaited.
*/
export const watchKeyUploads =
(inner: typeof fetch): typeof fetch =>
async (input, init) => {
const res = await inner(input, init);
try {
if (isKeyUploadUrl(urlOf(input))) {
if (res.ok) recordKeyUpload(res.status, '');
else if (res.status === 400)
res
.clone()
.text()
.then((body) => recordKeyUpload(res.status, body))
.catch(() => undefined);
}
} catch {
// observation must never break the request
}
return res;
};
+32
View File
@@ -33,6 +33,7 @@ import {
spotifyEmbedHeight,
parseMediaEmbed,
isTwitterNoResults,
getVimeoEventParts,
} from './videoEmbed';
const HOST = 'chat.lotusguild.org';
@@ -429,3 +430,34 @@ test('isTwitterNoResults: ignores normal embed traffic and junk', () => {
assert.equal(isTwitterNoResults(null), false);
assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false);
});
test('[#200] Vimeo live events embed from vimeo.com/event/{id}/embed', () => {
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567'), {
id: '4321567',
hash: undefined,
});
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567/a1b2c3d4e5'), {
id: '4321567',
hash: 'a1b2c3d4e5',
});
assert.equal(
getVimeoEventParts('https://vimeo.com/event/4321567/embed/a1b2c3d4e5')?.hash,
'a1b2c3d4e5',
);
assert.equal(getVimeoEventParts('https://vimeo.com/event/4321567/chat'), null);
assert.equal(getVimeoEventParts('https://vimeo.com/123456789'), null);
assert.equal(getVimeoEventParts('https://evil.example/event/4321567'), null);
assert.equal(
parseMediaEmbed('https://vimeo.com/event/4321567', 'h')?.embedUrl,
'https://vimeo.com/event/4321567/embed?autoplay=1&dnt=1',
);
assert.equal(
parseMediaEmbed('https://vimeo.com/event/4321567/a1b2c3d4e5', 'h')?.embedUrl,
'https://vimeo.com/event/4321567/embed/a1b2c3d4e5?autoplay=1&dnt=1',
);
// An ordinary video still goes to the player.
assert.match(
parseMediaEmbed('https://vimeo.com/123456789', 'h')?.embedUrl ?? '',
/^https:\/\/player\.vimeo\.com\/video\/123456789/,
);
});
+27
View File
@@ -80,6 +80,25 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
}
}
/**
* [Gitea #200] Vimeo live events: vimeo.com/event/{id}, or /event/{id}/{hash}
* when unlisted. Embedded from vimeo.com itself (not player.vimeo.com); the
* web and desktop CSP `frame-src https:` already allow it.
*/
export function getVimeoEventParts(url: string): { id: string; hash?: string } | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/event\/(\d+)(?:\/(?:embed\/)?([0-9a-f]{6,}))?\/?$/);
return m ? { id: m[1], hash: m[2] } : null;
} catch {
return null;
}
}
export const buildVimeoEventEmbedUrl = (id: string, hash?: string): string =>
`https://vimeo.com/event/${enc(id)}/embed${hash ? `/${enc(hash)}` : ''}?autoplay=1&dnt=1`;
export function getVimeoVideoId(url: string): string | null {
return getVimeoParts(url)?.id ?? null;
}
@@ -713,6 +732,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
embedUrl: buildVideoEmbedUrl('youtube', ytId),
};
const vimeoEvent = getVimeoEventParts(url);
if (vimeoEvent)
return {
provider: 'vimeo',
kind: 'landscape',
embedUrl: buildVimeoEventEmbedUrl(vimeoEvent.id, vimeoEvent.hash),
};
const vimeo = getVimeoParts(url);
if (vimeo)
return {
+21 -13
View File
@@ -3,11 +3,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
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 { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher';
import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { watchKeyUploads } from '../app/utils/keyUploadWatch';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
import { clearSharedPayload } from '../swShare';
@@ -61,12 +61,19 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
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 =
// OIDC/next-gen-auth sessions carry a refresh token. Since SDK 43 the client
// refreshes them itself; we persist the rotated tokens and serialise
// refreshes across tabs (oidcTokenRefresher).
const oidcIdentity: OidcIdentity | undefined =
session.refreshToken && session.oidc
? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl)
? {
userId: session.userId,
deviceId: session.deviceId,
baseUrl: session.baseUrl,
oidc: session.oidc,
}
: undefined;
if (oidcIdentity) installRefreshGuard(oidcIdentity);
const mx = createClient({
baseUrl: session.baseUrl,
@@ -76,12 +83,16 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
store: indexedDBStore,
cryptoStore: legacyCryptoStore,
deviceId: session.deviceId,
// [Gitea #201] Observe /keys/upload so a diverged OTK store is reported to
// the user (KeyUploadBanner) instead of retrying silently forever.
fetchFn: watchKeyUploads((input, init) => globalThis.fetch(input, init)),
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)
oauthClientId: oidcIdentity?.oidc.clientId,
onTokenRefresh: oidcIdentity
? (tokens) => persistRefreshedTokens(oidcIdentity, tokens)
: undefined,
});
@@ -126,11 +137,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
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);
}
// For OIDC sessions the SDK revokes the tokens at the auth server instead of
// calling /logout (matrix-js-sdk 43+); best-effort either way.
try {
await mx.logout();
} catch {
-29
View File
@@ -1,29 +0,0 @@
import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk';
import { Session } from '../app/state/sessions';
/**
* Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation
* endpoint during logout. Tolerant of any failure — logout proceeds regardless
* (the local session is cleared by the caller either way).
*/
export const revokeOidcTokens = async (session: Session): Promise<void> => {
if (!session.oidc) return;
try {
const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer);
const endpoint = config.revocation_endpoint;
if (!endpoint) return;
const { clientId } = session.oidc;
const revoke = (token: string, hint: string): Promise<Response> =>
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }),
});
const requests: Promise<Response>[] = [];
if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token'));
if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token'));
await Promise.allSettled(requests);
} catch {
/* issuer unreachable / no revocation endpoint — ignore */
}
};
+118
View File
@@ -0,0 +1,118 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
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('../app/state/sessions');
const { guardRefreshFetch, refreshGrantToken } = await import('./oidcTokenRefresher');
const id = {
userId: '@a:hs.example',
deviceId: 'DEV1',
baseUrl: 'https://hs.example',
oidc: { issuer: 'https://auth.example/', clientId: 'c1', redirectUri: 'https://app/cb' },
};
const grant = (refresh: string): RequestInit => {
const body = new URLSearchParams();
body.append('grant_type', 'refresh_token');
body.append('client_id', 'c1');
body.append('refresh_token', refresh);
return { method: 'POST', body };
};
const persist = (access: string, refresh: string) =>
sessions.setFallbackSession(access, id.deviceId, id.userId, id.baseUrl, {
refreshToken: refresh,
oidc: id.oidc,
expiresInMs: 300_000,
});
test('[#16] recognises only refresh-token grants', () => {
assert.equal(refreshGrantToken(grant('r1')), 'r1');
assert.equal(
refreshGrantToken({ method: 'POST', body: 'grant_type=refresh_token&refresh_token=r2' }),
'r2',
);
assert.equal(
refreshGrantToken({ method: 'POST', body: 'grant_type=authorization_code&code=x' }),
undefined,
);
assert.equal(refreshGrantToken({ method: 'GET' }), undefined);
assert.equal(refreshGrantToken(undefined), undefined);
});
test('[#16] a real refresh is persisted before the lock is released', async () => {
store.clear();
persist('at1', 'r1');
let calls = 0;
const inner: typeof fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({
token_type: 'Bearer',
access_token: 'at2',
refresh_token: 'r2',
expires_in: 300,
}),
{ status: 200 },
);
};
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
assert.equal(calls, 1);
assert.equal((await res.json()).access_token, 'at2', 'the SDK still reads the body');
const s = sessions.getFallbackSession();
assert.equal(s?.accessToken, 'at2');
assert.equal(s?.refreshToken, 'r2');
assert.ok((s?.expiresInMs ?? 0) > 290_000);
});
test('[#16] the losing tab adopts the winner instead of spending a used token', async () => {
store.clear();
persist('at-winner', 'r-winner'); // a sibling tab already rotated r-old
let calls = 0;
const inner: typeof fetch = async () => {
calls += 1;
return new Response('{"error":"invalid_grant"}', { status: 400 });
};
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r-old'));
assert.equal(calls, 0, 'never hits the issuer');
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.token_type, 'Bearer');
assert.equal(body.access_token, 'at-winner');
assert.equal(body.refresh_token, 'r-winner');
});
test('[#16] another account in storage is never adopted', async () => {
store.clear();
sessions.setFallbackSession('at-other', 'DEV9', '@b:hs.example', id.baseUrl, {
refreshToken: 'r-other',
});
let calls = 0;
const inner: typeof fetch = async () => {
calls += 1;
return new Response(JSON.stringify({ token_type: 'Bearer', access_token: 'at2' }), {
status: 200,
});
};
await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
assert.equal(calls, 1);
});
test('[#16] other requests pass straight through', async () => {
let seen = '';
const inner: typeof fetch = async (input) => {
seen = String(input);
return new Response('{}');
};
await guardRefreshFetch(inner, id)('https://hs.example/_matrix/client/v3/sync');
assert.equal(seen, 'https://hs.example/_matrix/client/v3/sync');
});
+99 -71
View File
@@ -1,9 +1,13 @@
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
import type { IdTokenClaims } from 'oidc-client-ts';
import type { AccessTokens } from 'matrix-js-sdk';
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
// Since matrix-js-sdk 43 the SDK refreshes OAuth tokens itself (MatrixClient
// `oauthClientId` + `onTokenRefresh`). This module keeps the two things Lotus
// added on top: rotated tokens are persisted (F5), and refreshes are
// serialised across tabs (Gitea #16).
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
// runs its own refresher against the SAME stored refresh token; with rotating
// runs its own client against the SAME stored refresh token; with rotating
// refresh tokens the second tab to hit the issuer gets `invalid_grant` and is
// signed out. Holding the lock while refreshing (and re-reading storage once
// inside it) makes the loser adopt the winner's tokens instead.
@@ -19,82 +23,106 @@ export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
return locks.request(REFRESH_LOCK_NAME, fn);
};
export type OidcIdentity = {
userId: string;
deviceId: string;
baseUrl: string;
oidc: OidcSessionMeta;
};
/** F5 — keep the stored session (and its expiry) in step with every refresh. */
export const persistRefreshedTokens = (id: OidcIdentity, tokens: AccessTokens): void => {
const expiresInMs =
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
setFallbackSession(tokens.accessToken, id.deviceId, id.userId, id.baseUrl, {
refreshToken: tokens.refreshToken,
oidc: id.oidc,
expiresInMs,
});
};
/** The refresh token a request is spending, if it is an OAuth refresh grant. */
export const refreshGrantToken = (init?: RequestInit): string | undefined => {
if (!init || (init.method ?? 'GET').toUpperCase() !== 'POST') return undefined;
const { body } = init;
let params: URLSearchParams | undefined;
if (body instanceof URLSearchParams) params = body;
else if (typeof body === 'string') params = new URLSearchParams(body);
if (params?.get('grant_type') !== 'refresh_token') return undefined;
return params.get('refresh_token') ?? undefined;
};
/**
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
* so a page reload keeps the freshest access/refresh token. The matrix client
* calls this automatically (reactively on a 401) when a refresh token is set.
* #16 — wrap `fetch` so OAuth refresh grants run one at a time across tabs.
* Inside the lock: if storage already holds a DIFFERENT refresh token for this
* user + device, a sibling tab won the race, so answer with its tokens (a
* well-formed token response) instead of spending a consumed refresh token.
* Comparing refresh tokens (not access tokens) can never adopt the token that
* just failed, so this cannot loop. A real refresh is persisted BEFORE the lock
* is released, so the next tab in line sees it. Every other request passes
* straight through.
*/
export class LotusOidcTokenRefresher extends OidcTokenRefresher {
private readonly deviceIdRef: string;
private readonly userIdRef: string;
private readonly baseUrlRef: string;
private readonly oidcRef: OidcSessionMeta;
constructor(oidc: OidcSessionMeta, deviceId: string, userId: string, baseUrl: string) {
super(
oidc.issuer,
oidc.clientId,
oidc.redirectUri,
deviceId,
(oidc.idTokenClaims ?? {}) as unknown as IdTokenClaims,
);
this.deviceIdRef = deviceId;
this.userIdRef = userId;
this.baseUrlRef = baseUrl;
this.oidcRef = oidc;
}
// #16 — before touching the issuer, check whether another tab already rotated
// the tokens. `refreshToken` is exactly what the SDK currently holds, so a
// DIFFERENT stored refresh token (same user + device) means a sibling tab won
// the race: adopt its tokens instead of burning a possibly-consumed refresh
// token. Comparing refresh tokens (not access tokens) can never adopt the very
// token that just 401'd, so this cannot loop. The whole thing runs under a
// cross-tab Web Lock so concurrent refreshes serialise and the waiter sees
// the winner's write.
public doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
export const guardRefreshFetch =
(inner: typeof fetch, id: OidcIdentity): typeof fetch =>
(input, init) => {
const spending = refreshGrantToken(init);
if (!spending) return inner(input, init);
return withRefreshLock(async () => {
const stored = getFallbackSession();
if (
stored &&
stored.userId === this.userIdRef &&
stored.deviceId === this.deviceIdRef &&
stored.userId === id.userId &&
stored.deviceId === id.deviceId &&
stored.refreshToken &&
stored.refreshToken !== refreshToken
stored.refreshToken !== spending
) {
return {
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
expiry:
typeof stored.expiresInMs === 'number'
? new Date(Date.now() + stored.expiresInMs)
: undefined,
};
return new Response(
JSON.stringify({
token_type: 'Bearer',
access_token: stored.accessToken,
refresh_token: stored.refreshToken,
...(typeof stored.expiresInMs === 'number'
? { expires_in: Math.floor(stored.expiresInMs / 1000) }
: {}),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
return super.doRefreshAccessToken(refreshToken);
const started = Date.now();
const res = await inner(input, init);
if (res.ok) {
try {
const body = (await res.clone().json()) as {
access_token?: unknown;
refresh_token?: unknown;
expires_in?: unknown;
};
if (typeof body.access_token === 'string') {
persistRefreshedTokens(id, {
accessToken: body.access_token,
refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined,
expiry:
typeof body.expires_in === 'number'
? new Date(started + body.expires_in * 1000)
: undefined,
});
}
} catch {
// unreadable body: the SDK will report the failure itself
}
}
return res;
});
}
};
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
// reloads instead of going stale. The SDK invokes persistTokens synchronously
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
// tokens object at runtime, even though its published type omits it — so read
// it here directly (a doRefreshAccessToken override would run too late, since
// persistTokens is called before that method returns).
protected async persistTokens(tokens: {
accessToken: string;
refreshToken?: string;
expiry?: Date;
}): Promise<void> {
const expiresInMs =
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
refreshToken: tokens.refreshToken,
oidc: this.oidcRef,
expiresInMs,
});
}
}
let unguardedFetch: typeof fetch | undefined;
/**
* Install the #16 guard on `globalThis.fetch`. The SDK's OAuth calls use the
* global fetch directly (not the client's `fetchFn`), so this is the only
* place to catch them. Idempotent: re-installing replaces the guard.
*/
export const installRefreshGuard = (id: OidcIdentity): void => {
unguardedFetch ??= globalThis.fetch.bind(globalThis);
globalThis.fetch = guardRefreshFetch(unguardedFetch, id);
};
+85 -67
View File
@@ -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(<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);
});
+4 -3
View File
@@ -327,9 +327,10 @@ export default defineConfig({
// Assets are content-hashed, so the filename is the cache key — don't
// append a revision cache-busting param.
dontCacheBustURLsMatching: /assets\//,
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
// (hash-busted and hot on every session) is precached deliberately.
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
// Raised above the 2 MB default so the matrix-sdk crypto wasm (~7.8 MB
// since crypto-wasm 18.9 / matrix-js-sdk 44; hash-busted and hot on every
// session) is precached deliberately.
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
},