Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 6b3f25e8a8 feat(seasonal): Christmas redesign — garland above the header, snow that shows on light themes (#240)
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 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 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 22m34s
- String lights are a real garland: a wire in shallow scallops with small
  bulbs hanging from it, all within the top ~16px so they stay above header
  icons and text (the old bokeh orbs covered the phone back/search icons).
  One repeating SVG tile; two bulb sets twinkle alternately (opacity only).
- Snow has a white core plus a blue-grey rim, so it shows on light themes
  (it was invisible) as well as dark; six-armed crystals drift in front.
- The frost vignette is gone and the bottom wash is lighter, so the composer
  area is no longer greyed on light themes.
- Reduced motion: static garland and a still scatter of snow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 23:09:14 -04:00
jared 71b7619082 Merge pull request 'chore(build): ship only the DTLN files that can load (-9 MB) (#210)' (#470) from chore/build-hygiene into lotus
CI / Build & Quality Checks (push) Successful in 3m41s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 29m27s
2026-10-25 22:48:04 -04:00
jared e4591f669b Merge pull request 'fix(status): status changes apply at once instead of waiting out the rate limit' (#469) from fix/status-save-speed into lotus
CI / Secret scan (gitleaks) (push) Successful in 19s
CI / Build & Quality Checks (push) Successful in 3m37s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 1m7s
CI / Playwright smoke (e2e) (push) Successful in 29m10s
2026-10-25 21:20:59 -04:00
jared 185970bc5a Merge pull request 'chore(types): typed secret storage key cache; drop the cryptoCallbacks cast (#210)' (#468) from chore/typing-hygiene into lotus
CI / Secret scan (gitleaks) (push) Successful in 33s
CI / Build & Quality Checks (push) Successful in 4m1s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-25 20:33:50 -04:00
Lotus CIandClaude Opus 5.5 d5b0b40b17 chore(build): ship only the DTLN files that can load (-9 MB) (#210)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Build & Quality Checks (pull_request) Successful in 3m2s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 30m35s
The DTLN noise-suppression package was copied whole into every build
(57 MB). Its threaded LiteRT build is only used on a cross-origin-isolated
page, and neither the web nor the desktop call page is (no COOP/COEP), so
its ~9 MB could never load; source maps and .d.ts files aren't needed at
runtime either. Leave those out: 57 -> 48 MB per web deploy and desktop
installer. Verified on the built output, not isolated: the DTLN worklet
reaches ready and runs a 300-frame benchmark, no 404s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 20:17:20 -04:00
Lotus CIandClaude Opus 5.5 b944456ab5 chore(types): typed secret storage key cache; drop the cryptoCallbacks cast (#210)
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 3m32s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m32s
secretStorageKeys was untyped JS, passed to createClient behind `as any`,
which hid that getSecretStorageKey returned undefined where the SDK type
says null. Now TypeScript against the SDK's CryptoCallbacks, returns null,
and storePrivateKey keeps its own copy of the key. Unit test added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 19:38:10 -04:00
7 changed files with 222 additions and 206 deletions
@@ -25,13 +25,13 @@ export const animSnowSway = keyframes({
});
/**
* String-light breathing — bokeh orbs softly pulse in brightness and scale,
* like incandescent bulbs warming and cooling. Opacity + transform only.
* String-light twinkle — each bulb set softly brightens and dims, like
* incandescent bulbs warming and cooling. Opacity only.
*/
export const animBulbBreathe = keyframes({
'0%': { transform: 'scale(0.92)', opacity: '0.55' },
'50%': { transform: 'scale(1.08)', opacity: '0.95' },
'100%': { transform: 'scale(0.92)', opacity: '0.55' },
'0%': { opacity: '0.6' },
'50%': { opacity: '1' },
'100%': { opacity: '0.6' },
});
/**
@@ -44,13 +44,3 @@ export const animAurora = keyframes({
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
'100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
});
/**
* Vignette frost — a barely-there breathing of the cold frame so the static
* tint feels alive without distracting motion.
*/
export const animFrostPulse = keyframes({
'0%': { opacity: '0.85' },
'50%': { opacity: '1' },
'100%': { opacity: '0.85' },
});
+152 -152
View File
@@ -1,12 +1,6 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animSnowFall,
animSnowSway,
animBulbBreathe,
animAurora,
animFrostPulse,
} from './Christmas.css';
import { animSnowFall, animSnowSway, animBulbBreathe, animAurora } from './Christmas.css';
// Deterministic pseudo-random so the scene is identical every mount (no React
// state per frame). Large primes keep the distribution well spread.
@@ -15,15 +9,71 @@ const rand = (seed: number) => {
return x - Math.floor(x);
};
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
const BULB_COLORS = [
'oklch(0.85 0.12 85)', // warm gold
'oklch(0.72 0.15 28)', // soft red
'oklch(0.95 0.03 230)', // icy white
'oklch(0.78 0.13 150)', // pine green
'oklch(0.8 0.1 235)', // cool blue
// [Gitea #240] The string lights are a real garland now: a thin wire in
// shallow scallops with small bulbs hanging from it, all inside the top ~16px so
// they sit above the header's icons and text instead of over them (phone
// header icons start ~18px down; the bulbs end by ~15px). One 600px
// SVG tile (five scallops, five colours) repeats across the width; bulbs are
// split into two sets that twinkle alternately (opacity only).
const TILE_W = 600;
const TILE_H = 18;
const SCALLOP = 120;
const svgUrl = (body: string) =>
`url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
)}")`;
const GARLAND_HUES = [
'oklch(0.66 0.2 27)', // red
'oklch(0.84 0.15 85)', // gold
'oklch(0.66 0.16 150)', // green
'oklch(0.66 0.14 245)', // blue
'oklch(0.93 0.05 90)', // warm white
];
const bulbsSvg = (which: 0 | 1, glow: boolean) =>
GARLAND_HUES.map((hue, k) => {
if (k % 2 !== which) return '';
const x = SCALLOP / 2 + k * SCALLOP;
if (glow) return `<ellipse cx='${x}' cy='11' rx='6' ry='6' fill='${hue}' opacity='0.55'/>`;
return (
`<rect x='${x - 1.5}' y='5.4' width='3' height='2.6' rx='0.7' fill='oklch(0.5 0.03 150)'/>` +
`<ellipse cx='${x}' cy='11.4' rx='2.7' ry='3.6' fill='${hue}'/>` +
`<ellipse cx='${x - 0.9}' cy='10' rx='0.8' ry='1.2' fill='white' opacity='0.7'/>`
);
}).join('');
const GLOW_FILTER =
"<filter id='g' x='-1' y='-1' width='3' height='3'><feGaussianBlur stdDeviation='2.6'/></filter>";
const WIRE = svgUrl(
`<path d='M0 1 Q${SCALLOP / 2} 10 ${SCALLOP} 1 ${Array.from(
{ length: TILE_W / SCALLOP - 1 },
(_, i) => `T${(i + 2) * SCALLOP} 1`,
).join(
' ',
)}' fill='none' stroke='oklch(0.58 0.05 150)' stroke-width='1.2' stroke-opacity='0.8'/>`,
);
const BULBS_A = svgUrl(
`${GLOW_FILTER}<g filter='url(#g)'>${bulbsSvg(0, true)}</g>${bulbsSvg(0, false)}`,
);
const BULBS_B = svgUrl(
`${GLOW_FILTER}<g filter='url(#g)'>${bulbsSvg(1, true)}</g>${bulbsSvg(1, false)}`,
);
// A six-armed snowflake for the near layer: a blue-grey halo stroke under a
// white one, so it reads on light themes as well as dark (no CSS filter).
const CRYSTAL_ARMS = Array.from({ length: 6 }, (_, i) => i * 60)
.map(
(a) =>
`<g transform='rotate(${a} 12 12)'><path d='M12 12V2.5M12 6l-2.6-2.4M12 6l2.6-2.4M12 9.2l-1.8-1.6M12 9.2l1.8-1.6'/></g>`,
)
.join('');
const crystalUrl = `url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none' stroke-linecap='round'><g stroke='oklch(0.55 0.05 245)' stroke-opacity='0.55' stroke-width='2.6'>${CRYSTAL_ARMS}</g><g stroke='white' stroke-width='1.2'>${CRYSTAL_ARMS}</g></g></svg>`,
)}")`;
// Snow dot: white core with a soft blue-grey rim and shadow — visible on white
// (the rim) and on dark (the core).
const FLAKE_BG =
'radial-gradient(circle at 38% 35%, oklch(1 0 0) 0%, oklch(0.97 0.01 230) 55%, oklch(0.9 0.03 235) 100%)';
const FLAKE_SHADOW = '0 0 0 0.6px oklch(0.6 0.04 240 / 0.55), 0 1px 2px oklch(0.35 0.05 245 / 0.3)';
type Flake = {
left: number;
@@ -35,22 +85,13 @@ type Flake = {
blur: number;
};
type Bulb = {
left: number;
top: number;
size: number;
color: string;
duration: number;
delay: number;
};
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
const flakes = useMemo<Flake[]>(() => {
const bands = [
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
{ count: 16, size: [2, 3], dur: [16, 22], op: [0.5, 0.7], blur: 0.4 },
{ count: 12, size: [3, 4.5], dur: [11, 15], op: [0.65, 0.85], blur: 0 },
{ count: 8, size: [4.5, 6.5], dur: [8, 11], op: [0.8, 1], blur: 0 },
];
const out: Flake[] = [];
let s = 1;
@@ -75,62 +116,53 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
return out;
}, []);
// Bokeh string lights strung along the very top edge, gently sagging.
const bulbs = useMemo<Bulb[]>(() => {
const count = 9;
const out: Bulb[] = [];
for (let i = 0; i < count; i += 1) {
const t = i / (count - 1);
// Two-segment garland sag so the lights drape rather than sit in a line.
const sag = Math.sin(t * Math.PI * 2) * 3.2;
out.push({
left: 4 + t * 92,
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
size: 12 + rand(i + 5) * 8,
color: BULB_COLORS[i % BULB_COLORS.length],
duration: 3.4 + rand(i + 2) * 2.6,
delay: -rand(i + 9) * 3,
});
}
return out;
}, []);
// A few large six-armed crystals drifting in front.
const crystals = useMemo<Flake[]>(
() =>
Array.from({ length: 6 }, (_, i) => ({
left: 6 + rand(i + 40) * 88,
size: 12 + rand(i + 41) * 5,
duration: 13 + rand(i + 42) * 6,
delay: -rand(i + 43) * 19,
swayDuration: 6 + rand(i + 44) * 4,
opacity: 0.55 + rand(i + 45) * 0.15,
blur: 0,
})),
[],
);
const garlandLayer = (image: string, delay: number): React.CSSProperties => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${TILE_H}px`,
backgroundImage: image,
backgroundRepeat: 'repeat-x',
backgroundSize: `${TILE_W}px ${TILE_H}px`,
// Start mid-scallop so the wire doesn't begin at a bulb on the far left.
backgroundPosition: '-30px 0',
animation: reduced ? 'none' : `${animBulbBreathe} 3.2s ease-in-out ${delay}s infinite`,
willChange: reduced ? undefined : 'opacity',
});
return (
<>
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
{/* Soft night-blue wash at the top only; the bottom stays clear so the
composer isn't greyed on light themes. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
'radial-gradient(120% 70% at 50% -12%, oklch(0.3 0.07 250 / 0.12) 0%, transparent 55%)',
'radial-gradient(70% 40% at 50% 112%, oklch(0.9 0.03 230 / 0.06) 0%, transparent 70%)',
].join(','),
}}
/>
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
single cheap layer for a faint icy haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.04)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
backgroundImage:
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
{/* Aurora shimmer high up — kept faint. */}
<div
aria-hidden="true"
style={{
@@ -138,99 +170,59 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
top: '-6%',
left: '-10%',
right: '-10%',
height: '40%',
contain: 'layout paint style',
height: '32%',
mixBlendMode: 'screen',
filter: 'blur(26px)',
opacity: reduced ? 0.6 : undefined,
opacity: reduced ? 0.45 : undefined,
backgroundImage: [
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.14) 0%, transparent 70%)',
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.12) 0%, transparent 72%)',
].join(','),
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* String-light wire — a faint catenary line the bulbs hang from. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '14%',
contain: 'layout paint style',
backgroundImage:
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
}}
/>
{/* Bokeh string lights — soft blurred orbs that breathe. */}
{bulbs.map((b, i) => (
<div
key={`bulb-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${b.left}%`,
top: `${b.top}%`,
width: `${b.size}px`,
height: `${b.size}px`,
marginLeft: `${-b.size / 2}px`,
borderRadius: '50%',
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
filter: 'blur(0.5px)',
opacity: reduced ? 0.9 : undefined,
animation: reduced
? 'none'
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
{/* Snowfall (motion) — three parallax bands plus the crystals. */}
{!reduced &&
flakes.map((f, i) => (
<div
key={`snow-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${f.left}%`,
width: `${f.size}px`,
height: `${f.size}px`,
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
willChange: 'transform',
}}
>
[...flakes, ...crystals].map((f, i) => {
const crystal = i >= flakes.length;
return (
<div
key={`snow-${i}`}
aria-hidden="true"
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background:
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
opacity: f.opacity,
filter: f.blur ? `blur(${f.blur}px)` : undefined,
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
willChange: 'transform, opacity',
position: 'absolute',
top: 0,
left: `${f.left}%`,
width: `${f.size}px`,
height: `${f.size}px`,
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
willChange: 'transform',
}}
/>
</div>
))}
>
<div
style={{
width: '100%',
height: '100%',
...(crystal
? { backgroundImage: crystalUrl, backgroundSize: '100% 100%' }
: { borderRadius: '50%', background: FLAKE_BG, boxShadow: FLAKE_SHADOW }),
opacity: f.opacity,
filter: f.blur ? `blur(${f.blur}px)` : undefined,
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
);
})}
{/* Static dusting of snow for the reduced-motion / preview scene — a
sparse scatter so the thumbnail still reads as snowfall. */}
{/* Static snow for reduced motion / the settings preview. */}
{reduced &&
flakes.map((f, i) => {
const fy = rand(i + 0.5) * 96 + 2;
[...flakes, ...crystals].map((f, i) => {
const crystal = i >= flakes.length;
const fy = rand(i + 0.5) * 88 + 8;
return (
<div
key={`snow-static-${i}`}
@@ -241,16 +233,24 @@ export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
top: `${fy}%`,
width: `${f.size}px`,
height: `${f.size}px`,
borderRadius: '50%',
background:
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
...(crystal
? {
backgroundImage: crystalUrl,
backgroundSize: '100% 100%',
transform: `rotate(${rand(i + 3) * 60}deg)`,
}
: { borderRadius: '50%', background: FLAKE_BG, boxShadow: FLAKE_SHADOW }),
opacity: f.opacity,
filter: f.blur ? `blur(${f.blur}px)` : undefined,
}}
/>
);
})}
{/* The garland on top of everything: wire, then two twinkling bulb sets. */}
<div aria-hidden="true" style={{ ...garlandLayer(WIRE, 0), animation: 'none' }} />
<div aria-hidden="true" style={garlandLayer(BULBS_A, 0)} />
<div aria-hidden="true" style={garlandLayer(BULBS_B, -1.6)} />
</>
);
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
// the user (KeyUploadBanner) instead of retrying silently forever.
fetchFn: watchKeyUploads((input, init) => globalThis.fetch(input, init)),
timelineSupport: true,
cryptoCallbacks: cryptoCallbacks as any,
cryptoCallbacks,
// 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'],
oauthClientId: oidcIdentity?.oidc.clientId,
-37
View File
@@ -1,37 +0,0 @@
const secretStorageKeys = new Map();
export function storePrivateKey(keyId, privateKey) {
if (privateKey instanceof Uint8Array === false) {
throw new Error('Unable to store, privateKey is invalid.');
}
secretStorageKeys.set(keyId, privateKey);
}
function hasPrivateKey(keyId) {
return secretStorageKeys.get(keyId) instanceof Uint8Array;
}
function getPrivateKey(keyId) {
return secretStorageKeys.get(keyId);
}
export function clearSecretStorageKeys() {
secretStorageKeys.clear();
}
async function getSecretStorageKey({ keys }) {
const keyIds = Object.keys(keys);
const keyId = keyIds.find(hasPrivateKey);
if (!keyId) return undefined;
const privateKey = getPrivateKey(keyId);
return [keyId, privateKey];
}
function cacheSecretStorageKey(keyId, keyInfo, privateKey) {
secretStorageKeys.set(keyId, privateKey);
}
export const cryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
};
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clearSecretStorageKeys, cryptoCallbacks, storePrivateKey } from './secretStorageKeys';
const ask = (...ids: string[]) =>
cryptoCallbacks.getSecretStorageKey!(
{ keys: Object.fromEntries(ids.map((id) => [id, {} as never])) },
'm.cross_signing.master',
);
test('[#210] secret storage key cache: null when unknown, key when stored, copies input', async () => {
clearSecretStorageKeys();
assert.equal(await ask('k1'), null);
const key = new Uint8Array([1, 2, 3]);
storePrivateKey('k1', key);
key[0] = 9; // the cache keeps its own copy
assert.deepEqual(await ask('k0', 'k1'), ['k1', new Uint8Array([1, 2, 3])]);
cryptoCallbacks.cacheSecretStorageKey!('k2', {} as never, new Uint8Array([7]));
assert.deepEqual(await ask('k2'), ['k2', new Uint8Array([7])]);
clearSecretStorageKeys();
assert.equal(await ask('k1', 'k2'), null);
assert.throws(() => storePrivateKey('k3', 'nope' as never), /invalid/);
});
+27
View File
@@ -0,0 +1,27 @@
import type { CryptoCallbacks } from 'matrix-js-sdk/lib/crypto-api';
const secretStorageKeys = new Map<string, Uint8Array<ArrayBuffer>>();
export function storePrivateKey(keyId: string, privateKey: Uint8Array) {
if (privateKey instanceof Uint8Array === false) {
throw new Error('Unable to store, privateKey is invalid.');
}
// Our own copy, on a plain ArrayBuffer (what the SDK's callback type wants).
secretStorageKeys.set(keyId, new Uint8Array(privateKey));
}
export function clearSecretStorageKeys() {
secretStorageKeys.clear();
}
export const cryptoCallbacks: CryptoCallbacks = {
async getSecretStorageKey({ keys }) {
const keyId = Object.keys(keys).find((id) => secretStorageKeys.get(id) instanceof Uint8Array);
const privateKey = keyId ? secretStorageKeys.get(keyId) : undefined;
// The SDK expects null (not undefined) when no key is cached.
return keyId && privateKey ? [keyId, privateKey] : null;
},
cacheSecretStorageKey(keyId, keyInfo, privateKey) {
secretStorageKeys.set(keyId, privateKey);
},
};
+11 -1
View File
@@ -189,7 +189,17 @@ function lotusDenoise() {
'Run `npm ci` to install @workadventure/noise-suppression.',
);
}
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
// [Gitea #210] Ship only what can load. The threaded LiteRT build is used
// only on a cross-origin-isolated page (COOP + COEP), and neither the web
// call page nor the desktop one is, so its ~9 MB never loads; source maps
// and type declarations aren't needed at runtime either. If the call page
// ever turns on COOP/COEP, drop the `threaded` exclusion.
const dtlnSkip = (src) =>
/litert_wasm_threaded_internal\./.test(src) || /\.(map|d\.ts)$/.test(src);
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), {
recursive: true,
filter: (src) => !dtlnSkip(src),
});
const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (dfnMissing.length > 0) {