atagen

malice

public
Star0
Watch0

Commit d8772d4eb408

tarball

atagen <boss@atagen.co>2026-10-26 04:43 UTC

26 files changed+680-474
web: make malice an installable PWA with an update prompt

The hosted build now gets a service worker too, built from the same sw.ts with the media bridge compiled out so it never intercepts /api. skipWaiting on install could swap code under a running tab, so a waiting worker now raises an update banner and applies on click or on the next cold start. The manifest is served no-store like index.html so installs never pin a stale one.
Modified file: crates/malice-static/src/lib.rs+1 additions−1 deletions
@@ -102,7 +102,7 @@ async fn missing_frontend() -> Response {
102102 }
103103
104104 fn is_no_cache_path(path: &str) -> bool {
105- matches!(path, "/" | "/index.html" | "/sw.js")
105+ matches!(path, "/" | "/index.html" | "/sw.js" | "/manifest.webmanifest")
106106 }
107107
108108 fn is_immutable_path(path: &str) -> bool {
Modified file: crates/malice-static/tests/static_only.rs+16 additions
@@ -77,6 +77,7 @@ async fn static_only_serves_the_bundle_and_exits_when_idle() {
7777 .expect("write index.html");
7878 std::fs::write(web_dir.path().join("sw.js"), "// service worker").expect("write sw.js");
7979 std::fs::write(web_dir.path().join("style.css"), "body {}").expect("write style.css");
80+ std::fs::write(web_dir.path().join("manifest.webmanifest"), "{}").expect("write manifest");
8081 std::fs::create_dir(web_dir.path().join("assets")).expect("create assets");
8182 std::fs::write(web_dir.path().join("assets/app-abc.wasm"), "wasm").expect("write wasm");
8283 std::fs::write(web_dir.path().join("assets/app-abc.wasm.br"), "brotli").expect("write br");
@@ -102,6 +103,21 @@ async fn static_only_serves_the_bundle_and_exits_when_idle() {
102103 assert!(sw.headers().get(header::LAST_MODIFIED).is_none());
103104 assert!(sw.headers().get(header::ETAG).is_none());
104105
106+ let manifest = client
107+ .get(format!("{base}/manifest.webmanifest"))
108+ .send()
109+ .await
110+ .unwrap();
111+ assert_eq!(manifest.status(), StatusCode::OK);
112+ assert_eq!(
113+ manifest.headers().get(header::CONTENT_TYPE).unwrap(),
114+ "application/manifest+json"
115+ );
116+ assert_eq!(
117+ manifest.headers().get(header::CACHE_CONTROL).unwrap(),
118+ "no-store"
119+ );
120+
105121 for path in ["/", "/sw.js"] {
106122 let conditional = client
107123 .get(format!("{base}{path}"))
Added file: web/icons/mark-maskable.svg+5 additions
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2+ <rect width="512" height="512" fill="#5865f2"/>
3+ <path d="M256 119 393 256 256 393 119 256Z" fill="none" stroke="#f4f5fb" stroke-width="22" stroke-linejoin="miter"/>
4+ <path d="M256 198 314 256 256 314 198 256Z" fill="#f4f5fb"/>
5+</svg>
Added file: web/icons/mark.svg+5 additions
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2+ <rect width="512" height="512" fill="#5865f2"/>
3+ <path d="M256 66 446 256 256 446 66 256Z" fill="none" stroke="#f4f5fb" stroke-width="30" stroke-linejoin="miter"/>
4+ <path d="M256 176 336 256 256 336 176 256Z" fill="#f4f5fb"/>
5+</svg>
Modified file: web/index.html+10 additions−1 deletions
@@ -2,7 +2,16 @@
22 <html lang="en">
33 <head>
44 <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
6+ <meta name="theme-color" content="#0c0d11" />
7+ <meta name="mobile-web-app-capable" content="yes" />
8+ <meta name="apple-mobile-web-app-capable" content="yes" />
9+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
10+ <meta name="apple-mobile-web-app-title" content="Malice" />
11+ <link rel="manifest" href="/manifest.webmanifest" />
12+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
13+ <link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
14+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
615 <title>Malice</title>
716 </head>
817 <body>
Binary file: web/public/apple-touch-icon.pngbinary

Binary file not shown.

Binary file: web/public/favicon-32.pngbinary

Binary file not shown.

Added file: web/public/favicon.svg+5 additions
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2+ <rect width="512" height="512" fill="#5865f2"/>
3+ <path d="M256 66 446 256 256 446 66 256Z" fill="none" stroke="#f4f5fb" stroke-width="30" stroke-linejoin="miter"/>
4+ <path d="M256 176 336 256 256 336 176 256Z" fill="#f4f5fb"/>
5+</svg>
Binary file: web/public/icon-192.pngbinary

Binary file not shown.

Binary file: web/public/icon-512.pngbinary

Binary file not shown.

Binary file: web/public/icon-maskable-512.pngbinary

Binary file not shown.

Added file: web/public/manifest.webmanifest+21 additions
@@ -0,0 +1,21 @@
1+{
2+ "name": "Malice",
3+ "short_name": "Malice",
4+ "id": "/",
5+ "start_url": "/",
6+ "scope": "/",
7+ "display": "standalone",
8+ "background_color": "#111218",
9+ "theme_color": "#0c0d11",
10+ "launch_handler": { "client_mode": ["navigate-existing", "auto"] },
11+ "icons": [
12+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
13+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
14+ { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
15+ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
16+ ],
17+ "shortcuts": [
18+ { "name": "Mentions", "url": "/?view=mentions", "icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }] },
19+ { "name": "Search", "url": "/?view=search", "icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }] }
20+ ]
21+}
Added file: web/scripts/icons.nu+11 additions
@@ -0,0 +1,11 @@
1+# Regenerates web/public icons from web/icons/*.svg. Run from web/ with `nix shell nixpkgs#resvg -c nu scripts/icons.nu`.
2+def render [src: string, out: string, size: int] {
3+ ^resvg -w $size -h $size $"icons/($src)" $"public/($out)"
4+}
5+
6+cp icons/mark.svg public/favicon.svg
7+render mark.svg icon-192.png 192
8+render mark.svg icon-512.png 512
9+render mark-maskable.svg icon-maskable-512.png 512
10+render mark.svg apple-touch-icon.png 180
11+render mark.svg favicon-32.png 32
Modified file: web/src/App.svelte+28 additions−3 deletions
@@ -28,6 +28,9 @@
2828 import ImageLightbox from './lib/ImageLightbox.svelte';
2929 import { lightbox } from './lib/lightbox';
3030 import SecurityPanel from './lib/SecurityPanel.svelte';
31+ import StorageBanner from './lib/StorageBanner.svelte';
32+ import { canDismissRecovery, recoveryPrompt } from './lib/protection-model';
33+ import { dismissRecoveryPrompt, installContext, loadDismissals, protectStorage, recoveryDismissed } from './lib/protection';
3134 import {
3235 closeRoom,
3336 createRoom,
@@ -54,6 +57,7 @@
5457 editMessage
5558 } from './lib/api';
5659 import EmojiText from './lib/EmojiText.svelte';
60+ import UpdateBanner from './lib/UpdateBanner.svelte';
5761 import { customEmotes, resolveEmotes } from './lib/emotes';
5862 import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
5963 import type { ParsedCommand } from './lib/commands';
@@ -92,6 +96,7 @@
9296 import type { Mention } from './lib/mentions';
9397 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
9498 import { loadKeywords } from './lib/keywords';
99+ import { startNotifications } from './lib/notifications';
95100 import {
96101 linkPreviews,
97102 loadUrlPreviewSettings,
@@ -227,7 +232,11 @@
227232 $: groupRooms = pinnedFirst(visibleRooms.filter((r) => !r.direct && !r.space && r.membership !== 'Invited'));
228233 $: spaceList = pinnedFirst(visibleRooms.filter((r) => r.space && r.membership !== 'Invited'));
229234 $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');
230- $: recoveryBanner = $encryption && ($encryption.recovery === 'Disabled' || $encryption.recovery === 'Incomplete') ? $encryption : null;
235+ $: if ($sessionUser) {
236+ loadDismissals($sessionUser);
237+ void protectStorage($sessionUser);
238+ }
239+ $: recoveryBanner = recoveryPrompt($encryption, installContext(), $recoveryDismissed);
231240 $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;
232241 $: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];
233242 $: currentStaged = $selectedRoomId ? (staged[$selectedRoomId] ?? []) : [];
@@ -306,8 +315,14 @@
306315 window.addEventListener('keydown', hidePointer, true);
307316 window.addEventListener('pointermove', movePointer);
308317 window.addEventListener('pointerdown', showPointer);
318+ const stopNotifications = startNotifications((roomId) => {
319+ selectedSpaceId = null;
320+ spaceChildIds = null;
321+ void selectRoom(roomId);
322+ });
309323 void boot();
310324 return () => {
325+ stopNotifications();
311326 window.removeEventListener('popstate', onPopState);
312327 window.removeEventListener('focus', onAttention);
313328 document.removeEventListener('visibilitychange', onAttention);
@@ -2383,6 +2398,7 @@
23832398 {/each}
23842399 </div>
23852400 {/if}
2401+ <UpdateBanner />
23862402 {#key mentionsOpen ? 'mentions' : searchOpen ? 'search' : $selectedRoomId ?? 'home'}
23872403 <div class="chat-view">
23882404 {#if mentionsOpen && $sessionUser}
@@ -2448,10 +2464,19 @@
24482464 {/if}
24492465 {#if recoveryBanner}
24502466 <div class="sec-banner warn" role="status">
2451- <span>Message history is locked</span>
2452- <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
2467+ {#if recoveryBanner === 'recover'}
2468+ <span>Message history is locked</span>
2469+ <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
2470+ {:else}
2471+ <span>Message keys are not backed up</span>
2472+ <button type="button" on:click={() => (dialog = 'security')}>Set up</button>
2473+ {#if canDismissRecovery(installContext())}
2474+ <button type="button" aria-label="Dismiss" on:click={() => $sessionUser && dismissRecoveryPrompt($sessionUser)}>✕</button>
2475+ {/if}
2476+ {/if}
24532477 </div>
24542478 {/if}
2479+ <StorageBanner on:open={() => (dialog = 'security')} />
24552480 {#if verificationBanner}
24562481 <div class="sec-banner" role="status">
24572482 {#if verificationBanner.kind === 'Incoming'}
Modified file: web/src/app.css+15 additions
@@ -323,6 +323,7 @@ input, textarea { font: inherit; }
323323 padding: 3px 10px;
324324 font-size: 12.5px;
325325 }
326+.sec-banner button + button { margin-left: 0; }
326327 .head-room { display: flex; align-items: center; gap: 10px; min-width: 0; }
327328 .head-room-text { min-width: 0; flex: 1; }
328329 .conn-banner.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-text); }
@@ -1152,3 +1153,17 @@ input, textarea { font: inherit; }
11521153 .pinned-list li { display: flex; align-items: center; gap: 6px; border-top: 1px solid var(--border); }
11531154 .pinned-strip .icon-btn { width: 26px; height: 26px; }
11541155 .history-gap { display: flex; align-items: center; justify-content: center; gap: 10px; margin: 12px 0; padding: 10px; border: 1px dashed var(--border); border-radius: var(--r-md); color: var(--muted); font-size: 12px; }
1156+
1157+/* ——— Safe areas ——— */
1158+.app { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }
1159+.chat-head { height: calc(50px + env(safe-area-inset-top)); padding-top: env(safe-area-inset-top); }
1160+.composer { padding-bottom: calc(8px + env(safe-area-inset-bottom)); }
1161+.account { padding-bottom: calc(8px + env(safe-area-inset-bottom)); }
1162+@media (max-width: 1100px) {
1163+ .app > .sidebar, .app > .members, .app > .thread { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
1164+ .account { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
1165+}
1166+@media (max-width: 640px) {
1167+ .chat-head { padding-top: calc(8px + env(safe-area-inset-top)); }
1168+ .composer { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
1169+}
Added file: web/src/lib/UpdateBanner.svelte+10 additions
@@ -0,0 +1,10 @@
1+<script lang="ts">
2+ import { applyUpdate, updateReady } from './sw-register';
3+</script>
4+
5+{#if $updateReady}
6+ <div class="sec-banner" role="status">
7+ <span>Update ready</span>
8+ <button type="button" on:click={applyUpdate}>Reload</button>
9+ </div>
10+{/if}
Modified file: web/src/lib/appearance.ts+3 additions
@@ -78,6 +78,7 @@ export const accent = persisted<string | null>('malice.accent', null, isAccent);
7878 if (typeof document !== 'undefined') {
7979 const root = document.documentElement;
8080 const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
81+ const themeColor = document.querySelector('meta[name="theme-color"]');
8182 const apply = () => {
8283 const current = get(theme);
8384 const custom = get(themePreview) ?? get(customThemes).find((t) => t.id === current);
@@ -96,6 +97,8 @@ if (typeof document !== 'undefined') {
9697 }
9798 const override = get(accent);
9899 if (override && !get(themePreview)) root.style.setProperty('--accent', override);
100+ const bar = getComputedStyle(root).getPropertyValue('--rail-bg').trim();
101+ if (bar) themeColor?.setAttribute('content', bar);
99102 };
100103 theme.subscribe(apply);
101104 customThemes.subscribe(apply);
Added file: web/src/lib/sw-register.ts+61 additions
@@ -0,0 +1,61 @@
1+import { writable } from 'svelte/store';
2+
3+const SW_RELOAD_GUARD = 'malice-sw-reload';
4+
5+export const updateReady = writable(false);
6+
7+let waiting: ServiceWorker | null = null;
8+
9+export function applyUpdate(): void {
10+ waiting?.postMessage({ type: 'SKIP_WAITING' });
11+}
12+
13+function watchForUpdates(registration: ServiceWorkerRegistration): void {
14+ const check = () => {
15+ if (registration.waiting && navigator.serviceWorker.controller) {
16+ waiting = registration.waiting;
17+ updateReady.set(true);
18+ }
19+ };
20+ check();
21+ registration.addEventListener('updatefound', () => {
22+ registration.installing?.addEventListener('statechange', check);
23+ });
24+ document.addEventListener('visibilitychange', () => {
25+ if (document.visibilityState === 'visible') void registration.update().catch(() => undefined);
26+ });
27+}
28+
29+/**
30+ * The device build needs the page controlled to reach its media bridge, so a shift-reload that
31+ * leaves it uncontrolled is recovered with a single guarded reload.
32+ */
33+function recoverUncontrolled(): void {
34+ if (navigator.serviceWorker.controller) {
35+ sessionStorage.removeItem(SW_RELOAD_GUARD);
36+ return;
37+ }
38+ if (sessionStorage.getItem(SW_RELOAD_GUARD)) return;
39+ sessionStorage.setItem(SW_RELOAD_GUARD, '1');
40+ window.location.reload();
41+}
42+
43+export function registerServiceWorker(): void {
44+ if (!('serviceWorker' in navigator) || !import.meta.env.PROD) return;
45+ let controlled = navigator.serviceWorker.controller !== null;
46+ navigator.serviceWorker.addEventListener('controllerchange', () => {
47+ if (!controlled) {
48+ controlled = true;
49+ return;
50+ }
51+ window.location.reload();
52+ });
53+ void navigator.serviceWorker
54+ .register('/sw.js', { type: 'module', updateViaCache: 'none' })
55+ .then((registration) => {
56+ watchForUpdates(registration);
57+ if (!__MALICE_DEVICE__) return;
58+ return navigator.serviceWorker.ready.then(recoverUncontrolled);
59+ })
60+ .catch(() => undefined);
61+}
Modified file: web/src/lib/transport-worker.ts−21 deletions
@@ -8,26 +8,6 @@ function toApiError(error: { kind: string; message: string }): ApiError {
88 return new ApiError(statusForKind(error.kind), error.message);
99 }
1010
11-const SW_RELOAD_GUARD = 'malice-sw-reload';
12-
13-/** Registers the service worker and recovers from a shift-reload leaving the page uncontrolled. */
14-function registerServiceWorker(): void {
15- if (!('serviceWorker' in navigator)) return;
16- void navigator.serviceWorker
17- .register('/sw.js', { type: 'module', updateViaCache: 'none' })
18- .then(() => navigator.serviceWorker.ready)
19- .then(() => {
20- if (navigator.serviceWorker.controller) {
21- sessionStorage.removeItem(SW_RELOAD_GUARD);
22- return;
23- }
24- if (sessionStorage.getItem(SW_RELOAD_GUARD)) return;
25- sessionStorage.setItem(SW_RELOAD_GUARD, '1');
26- window.location.reload();
27- })
28- .catch(() => undefined);
29-}
30-
3111 type Pending = { resolve: (value: unknown) => void; reject: (error: unknown) => void };
3212
3313 /**
@@ -46,7 +26,6 @@ class WorkerClient {
4626 private subscribed = new Set<string>();
4727
4828 constructor() {
49- registerServiceWorker();
5029 window.addEventListener('pagehide', () => {
5130 if (this.port) this.post({ kind: 'bye' });
5231 });
Modified file: web/src/main.ts+2 additions
@@ -1,9 +1,11 @@
11 import './app.css';
22 import { mount } from 'svelte';
33 import App from './App.svelte';
4+import { registerServiceWorker } from './lib/sw-register';
45
56 const target = document.getElementById('app');
67 if (!target) throw new Error('#app element missing');
8+registerServiceWorker();
79 const app = mount(App, { target });
810
911 export default app;
Added file: web/src/sw-media.ts+443 additions
@@ -0,0 +1,443 @@
1+import { statusForKind } from './lib/wire-status';
2+import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';
3+import { attachmentContentType } from './lib/media-sniff';
4+import { createAvatarLoader } from './lib/avatar-cache';
5+
6+export const AVATAR_CACHE = 'malice-avatars-v3';
7+
8+interface MediaReply {
9+ bytes?: Uint8Array<ArrayBuffer>;
10+ content_type?: string | null;
11+ error?: { kind: string; message: string };
12+}
13+
14+interface AttachmentEncryptionWire {
15+ key: string;
16+ iv: string;
17+ sha256: string;
18+}
19+
20+interface MediaFileWire {
21+ url: string;
22+ mimetype: string | null;
23+ declared_size: number | null;
24+ encryption: AttachmentEncryptionWire | null;
25+}
26+
27+type MediaLocationWire = { File: MediaFileWire } | { ServerThumbnail: { url: string; fallback: MediaFileWire } } | 'Local';
28+
29+interface LocateReply {
30+ location?: MediaLocationWire;
31+ token?: string | null;
32+ error?: { kind: string; message: string };
33+}
34+
35+const MEDIA_TIMEOUT_MS = 30000;
36+const pendingMedia = new Map<string, (reply: MediaReply & LocateReply) => void>();
37+let nextMediaRequestId = 0;
38+let mediaChannel: BroadcastChannel | undefined;
39+
40+function channel(): BroadcastChannel {
41+ if (!mediaChannel) {
42+ mediaChannel = new BroadcastChannel('malice-media');
43+ mediaChannel.onmessage = (ev: MessageEvent) => {
44+ const msg = ev.data as MediaReply & LocateReply & { id: string };
45+ const resolve = pendingMedia.get(msg.id);
46+ if (!resolve) return;
47+ pendingMedia.delete(msg.id);
48+ resolve(msg);
49+ };
50+ }
51+ return mediaChannel;
52+}
53+
54+function requestMedia(kind: 'avatar' | 'media', fields: Record<string, string>): Promise<MediaReply | null> {
55+ const id = `${Date.now()}-${nextMediaRequestId++}`;
56+ return new Promise((resolve) => {
57+ const timer = setTimeout(() => {
58+ pendingMedia.delete(id);
59+ resolve(null);
60+ }, MEDIA_TIMEOUT_MS);
61+ pendingMedia.set(id, (reply) => {
62+ clearTimeout(timer);
63+ resolve(reply);
64+ });
65+ channel().postMessage({ id, kind, ...fields });
66+ });
67+}
68+
69+interface LocateEntry {
70+ location: MediaLocationWire;
71+ token: string | null;
72+ expiresAt: number;
73+}
74+
75+const LOCATE_TTL_MS = 5 * 60 * 1000;
76+const locateCache = new Map<string, LocateEntry>();
77+const sniffCache = new Map<string, { contentType: string | null; expiresAt: number }>();
78+
79+function requestLocate(mediaId: string, size: string): Promise<{ location: MediaLocationWire; token: string | null } | null> {
80+ const id = `${Date.now()}-${nextMediaRequestId++}`;
81+ return new Promise((resolve) => {
82+ const timer = setTimeout(() => {
83+ pendingMedia.delete(id);
84+ resolve(null);
85+ }, MEDIA_TIMEOUT_MS);
86+ pendingMedia.set(id, (reply) => {
87+ clearTimeout(timer);
88+ resolve(reply.error || !reply.location ? null : { location: reply.location, token: reply.token ?? null });
89+ });
90+ channel().postMessage({ id, kind: 'locate', media_id: mediaId, size });
91+ });
92+}
93+
94+/** In-memory only: the access token never touches Cache Storage or IndexedDB, and expires with the entry. */
95+async function locate(mediaId: string, size: string, force: boolean): Promise<LocateEntry | null> {
96+ if (!force) {
97+ const cached = locateCache.get(mediaId);
98+ if (cached && cached.expiresAt > Date.now()) return cached;
99+ }
100+ const reply = await requestLocate(mediaId, size);
101+ if (!reply) return null;
102+ const entry: LocateEntry = { ...reply, expiresAt: Date.now() + LOCATE_TTL_MS };
103+ locateCache.set(mediaId, entry);
104+ return entry;
105+}
106+
107+interface RangeSlice {
108+ body: Uint8Array<ArrayBuffer>;
109+ status: number;
110+ contentRange?: string;
111+}
112+
113+/** The engine hands back the whole attachment at once, so seeking in <video>/<audio> needs this SW to slice it. */
114+function sliceForRange(bytes: Uint8Array<ArrayBuffer>, rangeHeader: string | null): RangeSlice {
115+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
116+ if (!match) return { body: bytes, status: 200 };
117+ const total = bytes.byteLength;
118+ let start = match[1] ? parseInt(match[1], 10) : undefined;
119+ let end = match[2] ? parseInt(match[2], 10) : undefined;
120+ if (start === undefined && end !== undefined) {
121+ start = Math.max(total - end, 0);
122+ end = total - 1;
123+ } else if (end === undefined) {
124+ end = total - 1;
125+ }
126+ if (start === undefined || start > end || start >= total) return { body: bytes, status: 200 };
127+ end = Math.min(end, total - 1);
128+ return { body: bytes.subarray(start, end + 1), status: 206, contentRange: `bytes ${start}-${end}/${total}` };
129+}
130+
131+function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, attachment: boolean): Response {
132+ if (!reply) return new Response(null, { status: 504 });
133+ if (reply.error) return new Response(reply.error.message, { status: statusForKind(reply.error.kind) });
134+ const bytes = reply.bytes ?? new Uint8Array();
135+ const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);
136+ const headers = new Headers({
137+ 'Content-Type': reply.content_type ?? 'application/octet-stream',
138+ 'Cache-Control': attachment ? 'private, max-age=3600' : 'private, max-age=31536000, immutable',
139+ 'X-Content-Type-Options': 'nosniff'
140+ });
141+ if (attachment) {
142+ headers.set('Content-Disposition', reply.content_type ? 'inline' : 'attachment');
143+ headers.set('Content-Security-Policy', 'sandbox');
144+ }
145+ if (contentRange) {
146+ headers.set('Content-Range', contentRange);
147+ headers.set('Accept-Ranges', 'bytes');
148+ }
149+ return new Response(body, { status, headers });
150+}
151+
152+interface ByteRange {
153+ start: number;
154+ end: number;
155+}
156+
157+/** Unlike sliceForRange, the total comes from the server's declared_size, not a buffer already in hand. */
158+function parseByteRange(rangeHeader: string | null, total: number): ByteRange | null {
159+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
160+ if (!match) return null;
161+ let start = match[1] ? parseInt(match[1], 10) : undefined;
162+ let end = match[2] ? parseInt(match[2], 10) : undefined;
163+ if (start === undefined && end !== undefined) {
164+ start = Math.max(total - end, 0);
165+ end = total - 1;
166+ } else if (end === undefined) {
167+ start = start ?? 0;
168+ end = total - 1;
169+ }
170+ if (start === undefined || start > end || start >= total) return null;
171+ return { start, end: Math.min(end, total - 1) };
172+}
173+
174+function attachmentHeaders(contentType: string | null): Headers {
175+ return new Headers({
176+ 'Content-Type': contentType ?? 'application/octet-stream',
177+ 'Cache-Control': 'private, max-age=3600',
178+ 'X-Content-Type-Options': 'nosniff',
179+ 'Content-Disposition': contentType ? 'inline' : 'attachment',
180+ 'Content-Security-Policy': 'sandbox'
181+ });
182+}
183+
184+/**
185+ * The Authorization header carries the account's access token, so it's only worth the risk on the
186+ * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named
187+ * (never a redirect target, should a future caller pass one through).
188+ */
189+function shouldAuthorize(fetchUrl: string, locatedUrl: string): boolean {
190+ try {
191+ const target = new URL(fetchUrl);
192+ const located = new URL(locatedUrl);
193+ if (target.origin !== located.origin) return false;
194+ return target.pathname.startsWith('/_matrix/client/v1/media/') || target.pathname.startsWith('/_matrix/media/');
195+ } catch {
196+ return false;
197+ }
198+}
199+
200+function concatChunks(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
201+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
202+ const out = new Uint8Array(total);
203+ let offset = 0;
204+ for (const part of parts) {
205+ out.set(part, offset);
206+ offset += part.byteLength;
207+ }
208+ return out;
209+}
210+
211+async function readUpTo(
212+ reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
213+ limit: number
214+): Promise<Uint8Array<ArrayBuffer>> {
215+ const chunks: Uint8Array[] = [];
216+ let total = 0;
217+ while (total < limit) {
218+ const { value, done } = await reader.read();
219+ if (done) break;
220+ chunks.push(value);
221+ total += value.byteLength;
222+ }
223+ reader.cancel().catch(() => undefined);
224+ return concatChunks(chunks).subarray(0, Math.min(limit, total));
225+}
226+
227+/**
228+ * Ciphertext in, plaintext out: skips the bytes the upstream sent but the caller didn't ask for
229+ * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte
230+ * alignment prefix from the very first output, and stops once `limit` bytes have been produced.
231+ */
232+async function* decryptedChunks(
233+ reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
234+ decryptor: RangeDecryptor | null,
235+ skipBytes: number,
236+ dropPrefix: number,
237+ limit: number
238+): AsyncGenerator<Uint8Array> {
239+ let toSkip = skipBytes;
240+ let toDrop = dropPrefix;
241+ let remaining = limit;
242+ while (remaining > 0) {
243+ const { value, done } = await reader.read();
244+ if (done) break;
245+ let chunk = value;
246+ if (toSkip > 0) {
247+ if (chunk.byteLength <= toSkip) {
248+ toSkip -= chunk.byteLength;
249+ continue;
250+ }
251+ chunk = chunk.subarray(toSkip);
252+ toSkip = 0;
253+ }
254+ const plain = decryptor ? await decryptor.push(chunk) : chunk;
255+ if (plain.byteLength === 0) continue;
256+ const emitted = emit(plain);
257+ if (emitted) yield emitted;
258+ }
259+ if (remaining > 0 && decryptor) {
260+ const emitted = emit(await decryptor.finish());
261+ if (emitted) yield emitted;
262+ }
263+ reader.cancel().catch(() => undefined);
264+
265+ function emit(chunk: Uint8Array): Uint8Array | null {
266+ let out = chunk;
267+ if (toDrop > 0) {
268+ if (out.byteLength <= toDrop) {
269+ toDrop -= out.byteLength;
270+ return null;
271+ }
272+ out = out.subarray(toDrop);
273+ toDrop = 0;
274+ }
275+ if (out.byteLength > remaining) out = out.subarray(0, remaining);
276+ remaining -= out.byteLength;
277+ return out.byteLength > 0 ? out : null;
278+ }
279+}
280+
281+async function headSniff(mediaId: string, file: MediaFileWire, token: string | null, retried: boolean): Promise<string | null> {
282+ const cached = sniffCache.get(mediaId);
283+ if (cached && cached.expiresAt > Date.now()) return cached.contentType;
284+ const headers = new Headers({ Range: 'bytes=0-79' });
285+ if (shouldAuthorize(file.url, file.url) && token) headers.set('Authorization', `Bearer ${token}`);
286+ let upstream: Response;
287+ try {
288+ upstream = await fetch(file.url, { headers });
289+ } catch {
290+ return null;
291+ }
292+ if (upstream.status === 401 && !retried) {
293+ const fresh = await locate(mediaId, 'Full', true);
294+ if (fresh && typeof fresh.location === 'object' && 'File' in fresh.location) {
295+ return headSniff(mediaId, fresh.location.File, fresh.token, true);
296+ }
297+ }
298+ if (!upstream.body || (!upstream.ok && upstream.status !== 206)) return null;
299+ const head = await readUpTo(upstream.body.getReader(), 80);
300+ let contentType: string | null;
301+ if (file.encryption) {
302+ const key = await importAttachmentKey(file.encryption.key);
303+ const iv = decodeAttachmentIv(file.encryption.iv);
304+ const decryptor = new RangeDecryptor(key, iv, 0);
305+ contentType = attachmentContentType(concatChunks([await decryptor.push(head), await decryptor.finish()]).subarray(0, 64));
306+ } else {
307+ contentType = attachmentContentType(head.subarray(0, 64));
308+ }
309+ sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
310+ return contentType;
311+}
312+
313+function wholeFileFallback(mediaId: string, rangeHeader: string | null): Promise<Response> {
314+ return requestMedia('media', { media_id: mediaId, size: 'Full' }).then((reply) => mediaResponse(reply, rangeHeader, true));
315+}
316+
317+async function streamFullMedia(mediaId: string, rangeHeader: string | null, entry?: LocateEntry, retried = false): Promise<Response> {
318+ const located = entry ?? (await locate(mediaId, 'Full', false));
319+ if (!located) return wholeFileFallback(mediaId, rangeHeader);
320+ if (typeof located.location !== 'object' || !('File' in located.location)) return wholeFileFallback(mediaId, rangeHeader);
321+ const file = located.location.File;
322+ const total = file.declared_size;
323+ if (total == null) return wholeFileFallback(mediaId, rangeHeader);
324+
325+ const requested = parseByteRange(rangeHeader, total);
326+ const start = requested?.start ?? 0;
327+ const end = requested?.end ?? total - 1;
328+ const alignedStart = start - (start % 16);
329+
330+ const headers = new Headers({ Range: `bytes=${alignedStart}-${end}` });
331+ if (shouldAuthorize(file.url, file.url) && located.token) headers.set('Authorization', `Bearer ${located.token}`);
332+
333+ let upstream: Response;
334+ try {
335+ upstream = await fetch(file.url, { headers });
336+ } catch {
337+ return new Response('media upstream unreachable', { status: 502 });
338+ }
339+ if (upstream.status === 401 && !retried) {
340+ const fresh = await locate(mediaId, 'Full', true);
341+ if (fresh) return streamFullMedia(mediaId, rangeHeader, fresh, true);
342+ }
343+ if (!upstream.body || (!upstream.ok && upstream.status !== 206)) {
344+ return new Response('media upstream error', { status: 502 });
345+ }
346+
347+ const decryptor = file.encryption
348+ ? new RangeDecryptor(await importAttachmentKey(file.encryption.key), decodeAttachmentIv(file.encryption.iv), alignedStart / 16)
349+ : null;
350+ const skipBytes = upstream.status === 206 ? 0 : alignedStart;
351+ const dropPrefix = start - alignedStart;
352+ const limit = end - start + 1;
353+
354+ const reader = upstream.body.getReader();
355+ const chunks = decryptedChunks(reader, decryptor, skipBytes, dropPrefix, limit);
356+
357+ const prefetched: Uint8Array[] = [];
358+ let prefetchedLen = 0;
359+ let chunksDone = false;
360+ let contentType: string | null;
361+
362+ if (alignedStart === 0) {
363+ while (prefetchedLen < 64) {
364+ const step = await chunks.next();
365+ if (step.done) {
366+ chunksDone = true;
367+ break;
368+ }
369+ prefetched.push(step.value);
370+ prefetchedLen += step.value.byteLength;
371+ }
372+ contentType = attachmentContentType(concatChunks(prefetched).subarray(0, Math.min(64, prefetchedLen)));
373+ sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
374+ } else {
375+ contentType = await headSniff(mediaId, file, located.token, retried);
376+ }
377+
378+ const body = new ReadableStream<Uint8Array>({
379+ async pull(controller) {
380+ const next = prefetched.shift();
381+ if (next) {
382+ controller.enqueue(next);
383+ return;
384+ }
385+ if (chunksDone) {
386+ controller.close();
387+ return;
388+ }
389+ const step = await chunks.next();
390+ if (step.done) controller.close();
391+ else controller.enqueue(step.value);
392+ },
393+ cancel() {
394+ reader.cancel().catch(() => undefined);
395+ }
396+ });
397+
398+ const responseHeaders = attachmentHeaders(contentType);
399+ responseHeaders.set('Accept-Ranges', 'bytes');
400+ if (requested) {
401+ responseHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`);
402+ return new Response(body, { status: 206, headers: responseHeaders });
403+ }
404+ return new Response(body, { status: 200, headers: responseHeaders });
405+}
406+
407+/** Keyed by the request URL, which names image and size, so cached bytes outlive the access token and survive restarts. */
408+let avatarLoader: ReturnType<typeof createAvatarLoader> | undefined;
409+
410+function cachedAvatar(url: string, query: string): Promise<Response> {
411+ avatarLoader ??= createAvatarLoader(
412+ async () => caches.open(AVATAR_CACHE),
413+ async (q) => mediaResponse(await requestMedia('avatar', Object.fromEntries(new URLSearchParams(q))), null, false)
414+ );
415+ return avatarLoader(url, query);
416+}
417+
418+export function handleMediaFetch(event: FetchEvent): boolean {
419+ const url = new URL(event.request.url);
420+ const range = event.request.headers.get('Range');
421+
422+ if (url.pathname === '/api/media') {
423+ const mxc = url.searchParams.get('mxc');
424+ if (mxc) {
425+ const size = url.searchParams.get('size') ?? '256';
426+ event.respondWith(cachedAvatar(event.request.url, new URLSearchParams({ mxc, size }).toString()));
427+ return true;
428+ }
429+ }
430+ if (url.pathname === '/api/message-media') {
431+ const mediaId = url.searchParams.get('media_id');
432+ const size = url.searchParams.get('size');
433+ if (mediaId && size === 'Full') {
434+ event.respondWith(streamFullMedia(mediaId, range));
435+ return true;
436+ }
437+ if (mediaId && size) {
438+ event.respondWith(requestMedia('media', { media_id: mediaId, size }).then((reply) => mediaResponse(reply, range, true)));
439+ return true;
440+ }
441+ }
442+ return false;
443+}
Modified file: web/src/sw.ts+27 additions−428 deletions
@@ -1,14 +1,12 @@
1-import { statusForKind } from './lib/wire-status';
2-import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';
3-import { attachmentContentType } from './lib/media-sniff';
4-import { createAvatarLoader } from './lib/avatar-cache';
1+import { AVATAR_CACHE, handleMediaFetch } from './sw-media';
2+
3+declare const __MALICE_DEVICE__: boolean;
54
65 // Prepended by vite.config.ts's precacheManifestPlugin: every emitted chunk,
76 // asset, and the wasm, as root-relative URLs.
87 const sw = self as unknown as ServiceWorkerGlobalScope & { __MALICE_PRECACHE__: string[] };
98
109 const CACHE_NAME = 'malice-shell-v1';
11-const AVATAR_CACHE = 'malice-avatars-v3';
1210
1311 sw.addEventListener('install', (event) => {
1412 event.waitUntil(
@@ -17,7 +15,10 @@ sw.addEventListener('install', (event) => {
1715 await cache.addAll(['/', '/index.html', ...sw.__MALICE_PRECACHE__]);
1816 })()
1917 );
20- void sw.skipWaiting();
18+});
19+
20+sw.addEventListener('message', (event) => {
21+ if (event.data?.type === 'SKIP_WAITING') void sw.skipWaiting();
2122 });
2223
2324 sw.addEventListener('activate', (event) => {
@@ -36,399 +37,6 @@ sw.addEventListener('activate', (event) => {
3637 );
3738 });
3839
39-interface MediaReply {
40- bytes?: Uint8Array<ArrayBuffer>;
41- content_type?: string | null;
42- error?: { kind: string; message: string };
43-}
44-
45-interface AttachmentEncryptionWire {
46- key: string;
47- iv: string;
48- sha256: string;
49-}
50-
51-interface MediaFileWire {
52- url: string;
53- mimetype: string | null;
54- declared_size: number | null;
55- encryption: AttachmentEncryptionWire | null;
56-}
57-
58-type MediaLocationWire = { File: MediaFileWire } | { ServerThumbnail: { url: string; fallback: MediaFileWire } } | 'Local';
59-
60-interface LocateReply {
61- location?: MediaLocationWire;
62- token?: string | null;
63- error?: { kind: string; message: string };
64-}
65-
66-const MEDIA_TIMEOUT_MS = 30000;
67-const mediaChannel = new BroadcastChannel('malice-media');
68-const pendingMedia = new Map<string, (reply: MediaReply & LocateReply) => void>();
69-let nextMediaRequestId = 0;
70-
71-mediaChannel.onmessage = (ev: MessageEvent) => {
72- const msg = ev.data as MediaReply & LocateReply & { id: string };
73- const resolve = pendingMedia.get(msg.id);
74- if (!resolve) return;
75- pendingMedia.delete(msg.id);
76- resolve(msg);
77-};
78-
79-function requestMedia(kind: 'avatar' | 'media', fields: Record<string, string>): Promise<MediaReply | null> {
80- const id = `${Date.now()}-${nextMediaRequestId++}`;
81- return new Promise((resolve) => {
82- const timer = setTimeout(() => {
83- pendingMedia.delete(id);
84- resolve(null);
85- }, MEDIA_TIMEOUT_MS);
86- pendingMedia.set(id, (reply) => {
87- clearTimeout(timer);
88- resolve(reply);
89- });
90- mediaChannel.postMessage({ id, kind, ...fields });
91- });
92-}
93-
94-interface LocateEntry {
95- location: MediaLocationWire;
96- token: string | null;
97- expiresAt: number;
98-}
99-
100-const LOCATE_TTL_MS = 5 * 60 * 1000;
101-const locateCache = new Map<string, LocateEntry>();
102-const sniffCache = new Map<string, { contentType: string | null; expiresAt: number }>();
103-
104-function requestLocate(mediaId: string, size: string): Promise<{ location: MediaLocationWire; token: string | null } | null> {
105- const id = `${Date.now()}-${nextMediaRequestId++}`;
106- return new Promise((resolve) => {
107- const timer = setTimeout(() => {
108- pendingMedia.delete(id);
109- resolve(null);
110- }, MEDIA_TIMEOUT_MS);
111- pendingMedia.set(id, (reply) => {
112- clearTimeout(timer);
113- resolve(reply.error || !reply.location ? null : { location: reply.location, token: reply.token ?? null });
114- });
115- mediaChannel.postMessage({ id, kind: 'locate', media_id: mediaId, size });
116- });
117-}
118-
119-/** In-memory only: the access token never touches Cache Storage or IndexedDB, and expires with the entry. */
120-async function locate(mediaId: string, size: string, force: boolean): Promise<LocateEntry | null> {
121- if (!force) {
122- const cached = locateCache.get(mediaId);
123- if (cached && cached.expiresAt > Date.now()) return cached;
124- }
125- const reply = await requestLocate(mediaId, size);
126- if (!reply) return null;
127- const entry: LocateEntry = { ...reply, expiresAt: Date.now() + LOCATE_TTL_MS };
128- locateCache.set(mediaId, entry);
129- return entry;
130-}
131-
132-interface RangeSlice {
133- body: Uint8Array<ArrayBuffer>;
134- status: number;
135- contentRange?: string;
136-}
137-
138-/** The engine hands back the whole attachment at once, so seeking in <video>/<audio> needs this SW to slice it. */
139-function sliceForRange(bytes: Uint8Array<ArrayBuffer>, rangeHeader: string | null): RangeSlice {
140- const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
141- if (!match) return { body: bytes, status: 200 };
142- const total = bytes.byteLength;
143- let start = match[1] ? parseInt(match[1], 10) : undefined;
144- let end = match[2] ? parseInt(match[2], 10) : undefined;
145- if (start === undefined && end !== undefined) {
146- start = Math.max(total - end, 0);
147- end = total - 1;
148- } else if (end === undefined) {
149- end = total - 1;
150- }
151- if (start === undefined || start > end || start >= total) return { body: bytes, status: 200 };
152- end = Math.min(end, total - 1);
153- return { body: bytes.subarray(start, end + 1), status: 206, contentRange: `bytes ${start}-${end}/${total}` };
154-}
155-
156-function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, attachment: boolean): Response {
157- if (!reply) return new Response(null, { status: 504 });
158- if (reply.error) return new Response(reply.error.message, { status: statusForKind(reply.error.kind) });
159- const bytes = reply.bytes ?? new Uint8Array();
160- const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);
161- const headers = new Headers({
162- 'Content-Type': reply.content_type ?? 'application/octet-stream',
163- 'Cache-Control': attachment ? 'private, max-age=3600' : 'private, max-age=31536000, immutable',
164- 'X-Content-Type-Options': 'nosniff'
165- });
166- if (attachment) {
167- headers.set('Content-Disposition', reply.content_type ? 'inline' : 'attachment');
168- headers.set('Content-Security-Policy', 'sandbox');
169- }
170- if (contentRange) {
171- headers.set('Content-Range', contentRange);
172- headers.set('Accept-Ranges', 'bytes');
173- }
174- return new Response(body, { status, headers });
175-}
176-
177-interface ByteRange {
178- start: number;
179- end: number;
180-}
181-
182-/** Unlike sliceForRange, the total comes from the server's declared_size, not a buffer already in hand. */
183-function parseByteRange(rangeHeader: string | null, total: number): ByteRange | null {
184- const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
185- if (!match) return null;
186- let start = match[1] ? parseInt(match[1], 10) : undefined;
187- let end = match[2] ? parseInt(match[2], 10) : undefined;
188- if (start === undefined && end !== undefined) {
189- start = Math.max(total - end, 0);
190- end = total - 1;
191- } else if (end === undefined) {
192- start = start ?? 0;
193- end = total - 1;
194- }
195- if (start === undefined || start > end || start >= total) return null;
196- return { start, end: Math.min(end, total - 1) };
197-}
198-
199-function attachmentHeaders(contentType: string | null): Headers {
200- return new Headers({
201- 'Content-Type': contentType ?? 'application/octet-stream',
202- 'Cache-Control': 'private, max-age=3600',
203- 'X-Content-Type-Options': 'nosniff',
204- 'Content-Disposition': contentType ? 'inline' : 'attachment',
205- 'Content-Security-Policy': 'sandbox'
206- });
207-}
208-
209-/**
210- * The Authorization header carries the account's access token, so it's only worth the risk on the
211- * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named
212- * (never a redirect target, should a future caller pass one through).
213- */
214-function shouldAuthorize(fetchUrl: string, locatedUrl: string): boolean {
215- try {
216- const target = new URL(fetchUrl);
217- const located = new URL(locatedUrl);
218- if (target.origin !== located.origin) return false;
219- return target.pathname.startsWith('/_matrix/client/v1/media/') || target.pathname.startsWith('/_matrix/media/');
220- } catch {
221- return false;
222- }
223-}
224-
225-function concatChunks(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
226- const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
227- const out = new Uint8Array(total);
228- let offset = 0;
229- for (const part of parts) {
230- out.set(part, offset);
231- offset += part.byteLength;
232- }
233- return out;
234-}
235-
236-async function readUpTo(
237- reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
238- limit: number
239-): Promise<Uint8Array<ArrayBuffer>> {
240- const chunks: Uint8Array[] = [];
241- let total = 0;
242- while (total < limit) {
243- const { value, done } = await reader.read();
244- if (done) break;
245- chunks.push(value);
246- total += value.byteLength;
247- }
248- reader.cancel().catch(() => undefined);
249- return concatChunks(chunks).subarray(0, Math.min(limit, total));
250-}
251-
252-/**
253- * Ciphertext in, plaintext out: skips the bytes the upstream sent but the caller didn't ask for
254- * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte
255- * alignment prefix from the very first output, and stops once `limit` bytes have been produced.
256- */
257-async function* decryptedChunks(
258- reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
259- decryptor: RangeDecryptor | null,
260- skipBytes: number,
261- dropPrefix: number,
262- limit: number
263-): AsyncGenerator<Uint8Array> {
264- let toSkip = skipBytes;
265- let toDrop = dropPrefix;
266- let remaining = limit;
267- while (remaining > 0) {
268- const { value, done } = await reader.read();
269- if (done) break;
270- let chunk = value;
271- if (toSkip > 0) {
272- if (chunk.byteLength <= toSkip) {
273- toSkip -= chunk.byteLength;
274- continue;
275- }
276- chunk = chunk.subarray(toSkip);
277- toSkip = 0;
278- }
279- const plain = decryptor ? await decryptor.push(chunk) : chunk;
280- if (plain.byteLength === 0) continue;
281- const emitted = emit(plain);
282- if (emitted) yield emitted;
283- }
284- if (remaining > 0 && decryptor) {
285- const emitted = emit(await decryptor.finish());
286- if (emitted) yield emitted;
287- }
288- reader.cancel().catch(() => undefined);
289-
290- function emit(chunk: Uint8Array): Uint8Array | null {
291- let out = chunk;
292- if (toDrop > 0) {
293- if (out.byteLength <= toDrop) {
294- toDrop -= out.byteLength;
295- return null;
296- }
297- out = out.subarray(toDrop);
298- toDrop = 0;
299- }
300- if (out.byteLength > remaining) out = out.subarray(0, remaining);
301- remaining -= out.byteLength;
302- return out.byteLength > 0 ? out : null;
303- }
304-}
305-
306-async function headSniff(mediaId: string, file: MediaFileWire, token: string | null, retried: boolean): Promise<string | null> {
307- const cached = sniffCache.get(mediaId);
308- if (cached && cached.expiresAt > Date.now()) return cached.contentType;
309- const headers = new Headers({ Range: 'bytes=0-79' });
310- if (shouldAuthorize(file.url, file.url) && token) headers.set('Authorization', `Bearer ${token}`);
311- let upstream: Response;
312- try {
313- upstream = await fetch(file.url, { headers });
314- } catch {
315- return null;
316- }
317- if (upstream.status === 401 && !retried) {
318- const fresh = await locate(mediaId, 'Full', true);
319- if (fresh && typeof fresh.location === 'object' && 'File' in fresh.location) {
320- return headSniff(mediaId, fresh.location.File, fresh.token, true);
321- }
322- }
323- if (!upstream.body || (!upstream.ok && upstream.status !== 206)) return null;
324- const head = await readUpTo(upstream.body.getReader(), 80);
325- let contentType: string | null;
326- if (file.encryption) {
327- const key = await importAttachmentKey(file.encryption.key);
328- const iv = decodeAttachmentIv(file.encryption.iv);
329- const decryptor = new RangeDecryptor(key, iv, 0);
330- contentType = attachmentContentType(concatChunks([await decryptor.push(head), await decryptor.finish()]).subarray(0, 64));
331- } else {
332- contentType = attachmentContentType(head.subarray(0, 64));
333- }
334- sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
335- return contentType;
336-}
337-
338-function wholeFileFallback(mediaId: string, rangeHeader: string | null): Promise<Response> {
339- return requestMedia('media', { media_id: mediaId, size: 'Full' }).then((reply) => mediaResponse(reply, rangeHeader, true));
340-}
341-
342-async function streamFullMedia(mediaId: string, rangeHeader: string | null, entry?: LocateEntry, retried = false): Promise<Response> {
343- const located = entry ?? (await locate(mediaId, 'Full', false));
344- if (!located) return wholeFileFallback(mediaId, rangeHeader);
345- if (typeof located.location !== 'object' || !('File' in located.location)) return wholeFileFallback(mediaId, rangeHeader);
346- const file = located.location.File;
347- const total = file.declared_size;
348- if (total == null) return wholeFileFallback(mediaId, rangeHeader);
349-
350- const requested = parseByteRange(rangeHeader, total);
351- const start = requested?.start ?? 0;
352- const end = requested?.end ?? total - 1;
353- const alignedStart = start - (start % 16);
354-
355- const headers = new Headers({ Range: `bytes=${alignedStart}-${end}` });
356- if (shouldAuthorize(file.url, file.url) && located.token) headers.set('Authorization', `Bearer ${located.token}`);
357-
358- let upstream: Response;
359- try {
360- upstream = await fetch(file.url, { headers });
361- } catch {
362- return new Response('media upstream unreachable', { status: 502 });
363- }
364- if (upstream.status === 401 && !retried) {
365- const fresh = await locate(mediaId, 'Full', true);
366- if (fresh) return streamFullMedia(mediaId, rangeHeader, fresh, true);
367- }
368- if (!upstream.body || (!upstream.ok && upstream.status !== 206)) {
369- return new Response('media upstream error', { status: 502 });
370- }
371-
372- const decryptor = file.encryption
373- ? new RangeDecryptor(await importAttachmentKey(file.encryption.key), decodeAttachmentIv(file.encryption.iv), alignedStart / 16)
374- : null;
375- const skipBytes = upstream.status === 206 ? 0 : alignedStart;
376- const dropPrefix = start - alignedStart;
377- const limit = end - start + 1;
378-
379- const reader = upstream.body.getReader();
380- const chunks = decryptedChunks(reader, decryptor, skipBytes, dropPrefix, limit);
381-
382- const prefetched: Uint8Array[] = [];
383- let prefetchedLen = 0;
384- let chunksDone = false;
385- let contentType: string | null;
386-
387- if (alignedStart === 0) {
388- while (prefetchedLen < 64) {
389- const step = await chunks.next();
390- if (step.done) {
391- chunksDone = true;
392- break;
393- }
394- prefetched.push(step.value);
395- prefetchedLen += step.value.byteLength;
396- }
397- contentType = attachmentContentType(concatChunks(prefetched).subarray(0, Math.min(64, prefetchedLen)));
398- sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
399- } else {
400- contentType = await headSniff(mediaId, file, located.token, retried);
401- }
402-
403- const body = new ReadableStream<Uint8Array>({
404- async pull(controller) {
405- const next = prefetched.shift();
406- if (next) {
407- controller.enqueue(next);
408- return;
409- }
410- if (chunksDone) {
411- controller.close();
412- return;
413- }
414- const step = await chunks.next();
415- if (step.done) controller.close();
416- else controller.enqueue(step.value);
417- },
418- cancel() {
419- reader.cancel().catch(() => undefined);
420- }
421- });
422-
423- const responseHeaders = attachmentHeaders(contentType);
424- responseHeaders.set('Accept-Ranges', 'bytes');
425- if (requested) {
426- responseHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`);
427- return new Response(body, { status: 206, headers: responseHeaders });
428- }
429- return new Response(body, { status: 200, headers: responseHeaders });
430-}
431-
43240 async function cacheFirst(request: Request): Promise<Response> {
43341 const cached = await caches.match(request);
43442 if (cached) return cached;
@@ -440,12 +48,6 @@ async function cacheFirst(request: Request): Promise<Response> {
44048 return response;
44149 }
44250
443-/** Keyed by the request URL, which names image and size, so cached bytes outlive the access token and survive restarts. */
444-const cachedAvatar = createAvatarLoader(
445- async () => caches.open(AVATAR_CACHE),
446- async (query) => mediaResponse(await requestMedia('avatar', Object.fromEntries(new URLSearchParams(query))), null, false)
447-);
448-
44951 async function networkFirstNavigation(request: Request): Promise<Response> {
45052 try {
45153 return await fetch(request);
@@ -458,30 +60,9 @@ async function networkFirstNavigation(request: Request): Promise<Response> {
45860
45961 sw.addEventListener('fetch', (event) => {
46062 const url = new URL(event.request.url);
461- const range = event.request.headers.get('Range');
46263
463- if (url.pathname === '/api/media') {
464- const mxc = url.searchParams.get('mxc');
465- if (mxc) {
466- const size = url.searchParams.get('size') ?? '256';
467- event.respondWith(cachedAvatar(event.request.url, new URLSearchParams({ mxc, size }).toString()));
468- return;
469- }
470- }
471- if (url.pathname === '/api/message-media') {
472- const mediaId = url.searchParams.get('media_id');
473- const size = url.searchParams.get('size');
474- if (mediaId && size === 'Full') {
475- event.respondWith(streamFullMedia(mediaId, range));
476- return;
477- }
478- if (mediaId && size) {
479- event.respondWith(
480- requestMedia('media', { media_id: mediaId, size }).then((reply) => mediaResponse(reply, range, true))
481- );
482- return;
483- }
484- }
64+ if (__MALICE_DEVICE__ && handleMediaFetch(event)) return;
65+ if (url.pathname.startsWith('/api/')) return;
48566 if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/twemoji/')) {
48667 event.respondWith(cacheFirst(event.request));
48768 return;
@@ -490,3 +71,21 @@ sw.addEventListener('fetch', (event) => {
49071 event.respondWith(networkFirstNavigation(event.request));
49172 }
49273 });
74+
75+sw.addEventListener('notificationclick', (event) => {
76+ event.notification.close();
77+ const roomId = (event.notification.data as { roomId?: unknown } | null)?.roomId;
78+ if (typeof roomId !== 'string') return;
79+ event.waitUntil(
80+ (async () => {
81+ const windows = await sw.clients.matchAll({ type: 'window', includeUncontrolled: true });
82+ const existing = windows.find((client) => client.visibilityState === 'visible') ?? windows[0];
83+ if (!existing) {
84+ await sw.clients.openWindow(`/?room=${encodeURIComponent(roomId)}`);
85+ return;
86+ }
87+ existing.postMessage({ type: 'malice-open-room', roomId });
88+ await existing.focus();
89+ })()
90+ );
91+});
Modified file: web/src/vite-env.d.ts+1 additions
@@ -1 +1,2 @@
11 /// <reference types="vite/client" />
2+declare const __MALICE_DEVICE__: boolean;
Modified file: web/tsconfig.json+1 additions−1 deletions
@@ -10,5 +10,5 @@
1010 "types": ["vite/client", "node"]
1111 },
1212 "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
13- "exclude": ["src/worker/**", "src/sw.ts"]
13+ "exclude": ["src/worker/**", "src/sw.ts", "src/sw-media.ts"]
1414 }
Modified file: web/tsconfig.worker.json+1 additions−1 deletions
@@ -11,5 +11,5 @@
1111 "types": ["vite/client"],
1212 "noEmit": true
1313 },
14- "include": ["src/worker/**/*.ts", "src/sw.ts", "src/lib/wire-status.ts", "src/lib/guards.ts"]
14+ "include": ["src/worker/**/*.ts", "src/sw.ts", "src/sw-media.ts", "src/lib/media-decrypt.ts", "src/lib/media-sniff.ts", "src/lib/avatar-cache.ts", "src/lib/wire-status.ts", "src/lib/guards.ts"]
1515 }
Modified file: web/vite.config.ts+14 additions−18 deletions
@@ -57,7 +57,8 @@ function twemojiPlugin(): import('vite').Plugin {
5757 export default defineConfig(({ mode }) => {
5858 const device = mode === 'device';
5959 return {
60- plugins: [svelte(), twemojiPlugin(), ...(device ? [precacheManifestPlugin()] : [])],
60+ plugins: [svelte(), twemojiPlugin(), precacheManifestPlugin()],
61+ define: { __MALICE_DEVICE__: JSON.stringify(device) },
6162 assetsInclude: ['**/*.wasm'],
6263 resolve: {
6364 alias: {
@@ -78,23 +79,18 @@ export default defineConfig(({ mode }) => {
7879 '/api': 'http://localhost:3000'
7980 }
8081 },
81- build: device
82- ? {
83- outDir: 'dist-device',
84- emptyOutDir: true,
85- rollupOptions: {
86- input: {
87- main: 'index.html',
88- sw: 'src/sw.ts'
89- },
90- output: {
91- entryFileNames: (chunk) => (chunk.name === 'sw' ? 'sw.js' : 'assets/[name]-[hash].js')
92- }
93- }
94- }
95- : {
96- outDir: 'dist',
97- emptyOutDir: true
82+ build: {
83+ outDir: device ? 'dist-device' : 'dist',
84+ emptyOutDir: true,
85+ rollupOptions: {
86+ input: {
87+ main: 'index.html',
88+ sw: 'src/sw.ts'
89+ },
90+ output: {
91+ entryFileNames: (chunk) => (chunk.name === 'sw' ? 'sw.js' : 'assets/[name]-[hash].js')
9892 }
93+ }
94+ }
9995 };
10096 });