Commit d8772d4eb408
tarball26 files changed+680-474
@@ -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 {@@ -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 = client107
+ .get(format!("{base}/manifest.webmanifest"))108
+ .send()109
+ .await110
+ .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 = client107123
.get(format!("{base}{path}"))@@ -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>@@ -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>@@ -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 not shown.
Binary file not shown.
@@ -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 not shown.
Binary file not shown.
Binary file not shown.
@@ -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
+}@@ -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.svg7
+render mark.svg icon-192.png 1928
+render mark.svg icon-512.png 5129
+render mark-maskable.svg icon-maskable-512.png 51210
+render mark.svg apple-touch-icon.png 18011
+render mark.svg favicon-32.png 32@@ -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
editMessage5558
} 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'}@@ -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
+}@@ -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}@@ -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);@@ -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 that31
+ * 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.serviceWorker54
+ .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
+}@@ -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.serviceWorker17
- .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
});@@ -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;@@ -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 the186
+ * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named187
+ * (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: number214
+): 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 for229
+ * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte230
+ * 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: number238
+): 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.encryption348
+ ? 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
+}@@ -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 the211
- * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named212
- * (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: number239
-): 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 for254
- * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte255
- * 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: number263
-): 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.encryption373
- ? 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
+});@@ -1 +1,2 @@
11
/// <reference types="vite/client" />2
+declare const __MALICE_DEVICE__: boolean;@@ -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
}@@ -11,5 +11,5 @@
1111
"types": ["vite/client"],1212
"noEmit": true1313
},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
}@@ -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: device82
- ? {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: true82
+ 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
});