atagen

malice

public
Star0
Watch0

Commit 235ce394f90d

tarball

atagen <boss@atagen.co>2026-10-26 06:54 UTC

14 files changed+655-17
web: subscribe to Web Push and show pushed notifications

Every push shows a notification because iOS revokes subscriptions after three silent pushes, and the device build then asks a running engine to decrypt and replaces it by tag. In-app notifications stand down while push is on so a message never notifies twice, and each browser tags its pusher so launch cleanup only removes its own stale entries.
Modified file: web/src/App.svelte+2 additions
@@ -100,6 +100,7 @@
100100 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
101101 import { loadKeywords } from './lib/keywords';
102102 import { startNotifications } from './lib/notifications';
103+ import { disablePush } from './lib/push';
103104 import { setRoomMode, setSpaceMuted, spaceMutedRooms, startMuteSync } from './lib/mute';
104105 import { isRoomMuted, mutingSpace, type MutedSpaces } from './lib/mute-model';
105106 import { loadMutedSpaces, mutedSpaces } from './lib/muted-spaces';
@@ -436,6 +437,7 @@
436437 async function doLogout(): Promise<void> {
437438 stopTyping();
438439 try {
440+ if ($sessionUser) await disablePush($sessionUser);
439441 await logout();
440442 } catch (e) {
441443 pushBanner(e instanceof Error ? e.message : 'Logout failed');
Modified file: web/src/lib/notifications.ts+22 additions−3 deletions
@@ -13,6 +13,8 @@ import {
1313 type NotificationPlan
1414 } from './notification-model';
1515 import { effectiveRooms } from './mute';
16+import { PUSH_CHANGED_MESSAGE } from './push-model';
17+import { disablePush, enablePush, pushActive, syncPush } from './push';
1618 import { selectedRoomId, sessionUser, timelines } from './store';
1719 import type { RoomSummary } from './types';
1820
@@ -52,11 +54,15 @@ export async function enableNotifications(): Promise<void> {
5254 const on = result === 'granted';
5355 writePreference(account, on);
5456 notificationsEnabled.set(on);
57+ if (on) await enablePush(account);
5558 }
5659
5760 export function disableNotifications(): void {
5861 const account = get(sessionUser);
59- if (account) writePreference(account, false);
62+ if (account) {
63+ writePreference(account, false);
64+ void disablePush(account);
65+ }
6066 notificationsEnabled.set(false);
6167 }
6268
@@ -113,7 +119,11 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
113119 let notified = new Set<string>();
114120 let badge = -1;
115121
116- const unsubscribeSession = sessionUser.subscribe((account) => notificationsEnabled.set(readPreference(account)));
122+ const unsubscribeSession = sessionUser.subscribe((account) => {
123+ const enabled = readPreference(account);
124+ notificationsEnabled.set(enabled);
125+ if (account) void syncPush(account, enabled);
126+ });
117127
118128 void registration().then(async (reg) => {
119129 for (const note of (await reg?.getNotifications()) ?? []) {
@@ -142,6 +152,7 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
142152 waiting.delete(roomId);
143153 const room = get(effectiveRooms).find((r) => r.room_id === roomId);
144154 if (!room || !active() || watched(roomId) || notifyingCount(room) === 0) return;
155+ if (get(pushActive)) return;
145156 const source = sourceFromTimeline(get(timelines)[roomId]) ?? sourceFromSummary(room);
146157 notified.add(roomId);
147158 void show(planNotification(room, source, { mention }), openRoom);
@@ -170,9 +181,17 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
170181
171182 const unsubscribeRooms = effectiveRooms.subscribe(onRooms);
172183 const unsubscribeEnabled = notificationsEnabled.subscribe(() => onRooms(get(effectiveRooms)));
173- const onAttention = () => clearRead(get(effectiveRooms));
184+ const onAttention = () => {
185+ notificationPermission.set(supported ? Notification.permission : 'unsupported');
186+ if (supported && Notification.permission !== 'granted') pushActive.set(false);
187+ clearRead(get(effectiveRooms));
188+ const open = get(selectedRoomId);
189+ if (open && get(pushActive) && watched(open)) void closeRoom(open);
190+ };
174191 const onMessage = (ev: MessageEvent) => {
175192 const data = ev.data as { type?: unknown; roomId?: unknown } | null;
193+ const account = get(sessionUser);
194+ if (data?.type === PUSH_CHANGED_MESSAGE && account) void syncPush(account, get(notificationsEnabled));
176195 if (data?.type === OPEN_ROOM_MESSAGE && typeof data.roomId === 'string') openRoom(data.roomId);
177196 };
178197 window.addEventListener('focus', onAttention);
Added file: web/src/lib/push-model.ts+182 additions
@@ -0,0 +1,182 @@
1+export const DEVICE_PUSH_APP_ID = 'dev.malice.device';
2+export const PUSH_CHANNEL = 'malice-push';
3+export const PUSH_CHANGED_MESSAGE = 'malice-push-changed';
4+export const PUSH_NOTIFY_PATH = '/_matrix/push/v1/notify';
5+
6+export interface PushDisplay {
7+ title: string;
8+ body: string;
9+ tag: string;
10+ navigate: string | null;
11+ silent: boolean;
12+ badge: number | null;
13+ roomId: string | null;
14+ eventId: string | null;
15+ mention: boolean;
16+ encrypted: boolean;
17+}
18+
19+export interface ResolvedPush {
20+ title: string;
21+ body: string;
22+}
23+
24+export interface PushSubscriptionKeys {
25+ endpoint: string;
26+ p256dh: string;
27+ auth: string;
28+}
29+
30+const FALLBACK_TITLE = 'New message';
31+const FALLBACK_TAG = 'malice';
32+
33+function record(value: unknown): Record<string, unknown> {
34+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
35+}
36+
37+function text(value: unknown): string | null {
38+ return typeof value === 'string' && value.length > 0 ? value : null;
39+}
40+
41+function count(value: unknown): number | null {
42+ const n = typeof value === 'string' ? Number(value) : value;
43+ return typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : null;
44+}
45+
46+function sameOrigin(url: string | null, origin: string): string | null {
47+ if (!url) return null;
48+ try {
49+ const parsed = new URL(url, origin);
50+ return parsed.origin === origin ? parsed.href : null;
51+ } catch {
52+ return null;
53+ }
54+}
55+
56+export function roomFromTag(tag: string | null | undefined): string | null {
57+ return tag?.startsWith('room:') && tag.length > 5 ? tag.slice(5) : null;
58+}
59+
60+/** Never throws, so the push handler can always show something. */
61+export function parsePush(raw: unknown, origin: string): PushDisplay {
62+ let value = raw;
63+ if (typeof raw === 'string') {
64+ try {
65+ value = JSON.parse(raw);
66+ } catch {
67+ value = null;
68+ }
69+ }
70+ const payload = record(value);
71+ const note = record(payload['notification']);
72+ const malice = record(payload['malice']);
73+ const roomId = text(malice['room_id']);
74+ const tag = text(note['tag']) ?? (roomId ? `room:${roomId}` : FALLBACK_TAG);
75+ const badge = count(note['app_badge']) ?? count(malice['unread']);
76+ return {
77+ title: text(note['title']) ?? FALLBACK_TITLE,
78+ body: text(note['body']) ?? '',
79+ tag,
80+ navigate: sameOrigin(text(note['navigate']), origin),
81+ silent: note['silent'] === true,
82+ badge,
83+ roomId: roomId ?? roomFromTag(tag),
84+ eventId: text(malice['event_id']),
85+ mention: malice['mention'] === true,
86+ encrypted: malice['encrypted'] === true
87+ };
88+}
89+
90+export function roomTarget(roomId: string | null, navigate: string | null): string {
91+ if (navigate) return navigate;
92+ return roomId ? `/?room=${encodeURIComponent(roomId)}` : '/';
93+}
94+
95+/** A visible focused window is already showing the app, so the banner stays but without sound or vibration. */
96+export function shouldSilence(display: PushDisplay, windowFocused: boolean): boolean {
97+ return display.silent || windowFocused;
98+}
99+
100+/** The decrypted reply replaces the tier 1 notification only when it adds something. */
101+export function parseResolved(value: unknown, current: PushDisplay): ResolvedPush | null {
102+ const reply = record(value);
103+ const title = text(reply['title']);
104+ const body = text(reply['body']);
105+ if (!title && !body) return null;
106+ const next = { title: title ?? current.title, body: body ?? current.body };
107+ return next.title === current.title && next.body === current.body ? null : next;
108+}
109+
110+export function urlBase64ToBytes(value: string): Uint8Array<ArrayBuffer> {
111+ const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
112+ const raw = atob(padded);
113+ const bytes = new Uint8Array(raw.length);
114+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
115+ return bytes;
116+}
117+
118+export function bytesToUrlBase64(bytes: Uint8Array): string {
119+ let raw = '';
120+ for (const byte of bytes) raw += String.fromCharCode(byte);
121+ return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
122+}
123+
124+export function subscriptionKeys(sub: { endpoint: string; getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null }): PushSubscriptionKeys | null {
125+ const p256dh = sub.getKey('p256dh');
126+ const auth = sub.getKey('auth');
127+ if (!p256dh || !auth) return null;
128+ return { endpoint: sub.endpoint, p256dh: bytesToUrlBase64(new Uint8Array(p256dh)), auth: bytesToUrlBase64(new Uint8Array(auth)) };
129+}
130+
131+export interface StoredPush {
132+ endpoint: string;
133+ pushkey: string | null;
134+}
135+
136+export type SyncAction = 'none' | 'subscribe' | 'register';
137+
138+/** What a launch check must repair, given whether the user wants push, the live subscription and what was last registered. */
139+export function syncAction(wanted: boolean, live: string | null, stored: StoredPush | null): SyncAction {
140+ if (!wanted) return 'none';
141+ if (!live) return 'subscribe';
142+ return stored?.endpoint === live ? 'none' : 'register';
143+}
144+
145+export function parseStoredPush(raw: string | null): StoredPush | null {
146+ if (!raw) return null;
147+ try {
148+ const value = record(JSON.parse(raw));
149+ const endpoint = text(value['endpoint']);
150+ return endpoint ? { endpoint, pushkey: text(value['pushkey']) } : null;
151+ } catch {
152+ return null;
153+ }
154+}
155+
156+export function devicePusherRequest(keys: PushSubscriptionKeys, origin: string, lang: string, browser: string) {
157+ return {
158+ op: 'set_pusher' as const,
159+ app_id: DEVICE_PUSH_APP_ID,
160+ pushkey: keys.p256dh,
161+ app_display_name: 'Malice',
162+ device_display_name: 'Malice web',
163+ lang,
164+ url: `${origin}${PUSH_NOTIFY_PATH}`,
165+ format: 'Full' as const,
166+ data: { endpoint: keys.endpoint, auth: keys.auth, browser }
167+ };
168+}
169+
170+/** Pushers this browser registered under an endpoint it no longer holds. Other browsers on the account share the app id, so only its own id matches. */
171+export function stalePushers(reply: unknown, browser: string, endpoint: string): string[] {
172+ const list = record(reply)['pushers'];
173+ if (!Array.isArray(list)) return [];
174+ const stale: string[] = [];
175+ for (const entry of list) {
176+ const pusher = record(entry);
177+ const data = record(pusher['data']);
178+ const pushkey = text(pusher['pushkey']);
179+ if (pusher['app_id'] === DEVICE_PUSH_APP_ID && data['browser'] === browser && data['endpoint'] !== endpoint && pushkey) stale.push(pushkey);
180+ }
181+ return stale;
182+}
Added file: web/src/lib/push.ts+166 additions
@@ -0,0 +1,166 @@
1+import { writable } from 'svelte/store';
2+import { runCommand } from './api';
3+import {
4+ DEVICE_PUSH_APP_ID,
5+ devicePusherRequest,
6+ parseStoredPush,
7+ stalePushers,
8+ subscriptionKeys,
9+ syncAction,
10+ urlBase64ToBytes,
11+ type PushSubscriptionKeys,
12+ type StoredPush
13+} from './push-model';
14+
15+/** True while this browser holds a subscription the server knows about, so in-app notifications stand down. */
16+export const pushActive = writable(false);
17+
18+const supported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window;
19+const storageKey = (account: string) => `malice.push.${account}`;
20+const browserKey = 'malice.push.browser';
21+const vapidPath = __MALICE_DEVICE__ ? '/push/vapid' : '/api/push/vapid';
22+
23+let queue: Promise<unknown> = Promise.resolve();
24+
25+/** Enable, disable and launch checks all touch one subscription, so they run one at a time. */
26+function serial<T>(task: () => Promise<T>): Promise<T> {
27+ const run = queue.then(task, task);
28+ queue = run.catch(() => undefined);
29+ return run;
30+}
31+
32+function browserId(): string {
33+ try {
34+ const existing = localStorage.getItem(browserKey);
35+ if (existing) return existing;
36+ const id = crypto.randomUUID();
37+ localStorage.setItem(browserKey, id);
38+ return id;
39+ } catch {
40+ return 'unknown';
41+ }
42+}
43+
44+function readStored(account: string): StoredPush | null {
45+ try {
46+ return parseStoredPush(localStorage.getItem(storageKey(account)));
47+ } catch {
48+ return null;
49+ }
50+}
51+
52+function writeStored(account: string, stored: StoredPush | null): void {
53+ try {
54+ if (stored) localStorage.setItem(storageKey(account), JSON.stringify(stored));
55+ else localStorage.removeItem(storageKey(account));
56+ } catch {}
57+}
58+
59+async function registration(): Promise<ServiceWorkerRegistration | null> {
60+ if (!supported) return null;
61+ return (await navigator.serviceWorker.getRegistration()) ?? null;
62+}
63+
64+async function applicationServerKey(): Promise<Uint8Array<ArrayBuffer>> {
65+ const res = await fetch(vapidPath, { credentials: 'same-origin' });
66+ if (!res.ok) throw new Error('push key unavailable');
67+ const body: unknown = await res.json().catch(() => null);
68+ const key = typeof body === 'string' ? body : (body as { public_key?: unknown } | null)?.public_key;
69+ if (typeof key !== 'string') throw new Error('push key malformed');
70+ return urlBase64ToBytes(key);
71+}
72+
73+async function post(path: string, body: unknown): Promise<void> {
74+ const res = await fetch(path, {
75+ method: 'POST',
76+ credentials: 'same-origin',
77+ headers: { 'content-type': 'application/json' },
78+ body: JSON.stringify(body)
79+ });
80+ if (!res.ok) throw new Error(`push request failed (${res.status})`);
81+}
82+
83+async function removeRemote(stored: StoredPush): Promise<void> {
84+ if (__MALICE_DEVICE__) {
85+ if (stored.pushkey) await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey: stored.pushkey });
86+ } else {
87+ await post('/api/push/unsubscribe', { endpoint: stored.endpoint });
88+ }
89+}
90+
91+async function register(account: string, keys: PushSubscriptionKeys): Promise<void> {
92+ const previous = readStored(account);
93+ if (__MALICE_DEVICE__) {
94+ await runCommand(devicePusherRequest(keys, window.location.origin, navigator.language, browserId()));
95+ if (previous?.pushkey && previous.pushkey !== keys.p256dh) {
96+ await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey: previous.pushkey }).catch(() => undefined);
97+ }
98+ } else {
99+ await post('/api/push/subscribe', { endpoint: keys.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } });
100+ }
101+ writeStored(account, { endpoint: keys.endpoint, pushkey: keys.p256dh });
102+}
103+
104+async function dropStale(endpoint: string): Promise<void> {
105+ const reply = await runCommand({ op: 'pushers' });
106+ for (const pushkey of stalePushers(reply, browserId(), endpoint)) {
107+ await runCommand({ op: 'remove_pusher', app_id: DEVICE_PUSH_APP_ID, pushkey });
108+ }
109+}
110+
111+async function subscribe(reg: ServiceWorkerRegistration): Promise<PushSubscription> {
112+ return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: await applicationServerKey() });
113+}
114+
115+async function activate(account: string, sub: PushSubscription): Promise<void> {
116+ const keys = subscriptionKeys(sub);
117+ if (!keys) throw new Error('subscription has no keys');
118+ await register(account, keys);
119+}
120+
121+/** Runs inside the settings toggle after permission is granted. */
122+export function enablePush(account: string): Promise<void> {
123+ return serial(async () => {
124+ try {
125+ const reg = await registration();
126+ if (!reg) return;
127+ await activate(account, (await reg.pushManager.getSubscription()) ?? (await subscribe(reg)));
128+ pushActive.set(true);
129+ } catch {
130+ pushActive.set(false);
131+ }
132+ });
133+}
134+
135+/** Removes the server side registration and the browser subscription. Used by the toggle and before logout. */
136+export function disablePush(account: string): Promise<void> {
137+ return serial(async () => {
138+ pushActive.set(false);
139+ const stored = readStored(account);
140+ if (stored) await removeRemote(stored).catch(() => undefined);
141+ writeStored(account, null);
142+ const sub = await (await registration())?.pushManager.getSubscription();
143+ await sub?.unsubscribe().catch(() => false);
144+ });
145+}
146+
147+/** Launch check. iOS revokes subscriptions after missed notifications and push services rotate endpoints. */
148+export function syncPush(account: string, wanted: boolean): Promise<void> {
149+ return serial(async () => {
150+ try {
151+ const reg = supported && Notification.permission === 'granted' ? await registration() : null;
152+ if (!reg) {
153+ pushActive.set(false);
154+ return;
155+ }
156+ let sub = await reg.pushManager.getSubscription();
157+ const action = syncAction(wanted, sub?.endpoint ?? null, readStored(account));
158+ if (action === 'subscribe') sub = await subscribe(reg);
159+ if (action !== 'none' && sub) await activate(account, sub);
160+ pushActive.set(wanted && sub !== null);
161+ if (__MALICE_DEVICE__ && wanted && sub) await dropStale(sub.endpoint).catch(() => undefined);
162+ } catch {
163+ pushActive.set(false);
164+ }
165+ });
166+}
Modified file: web/src/lib/trace-name.ts−7 deletions
@@ -10,10 +10,3 @@ export function traceFilter(name: string): string {
1010 return name.startsWith(PREFIX) ? name.slice(PREFIX.length) : '';
1111 }
1212
13-export function pageTraceFilter(): string | null {
14- try {
15- return localStorage.getItem('malice.trace');
16- } catch {
17- return null;
18- }
19-}
Added file: web/src/lib/trace-page.ts+7 additions
@@ -0,0 +1,7 @@
1+export function pageTraceFilter(): string | null {
2+ try {
3+ return localStorage.getItem('malice.trace');
4+ } catch {
5+ return null;
6+ }
7+}
Modified file: web/src/lib/transport-worker.ts+2 additions−1 deletions
@@ -3,7 +3,8 @@ import type { MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext }
33 import type { MemberSummary, RoomSummary } from './types';
44 import { ApiError, type CommandRequest, type SendKind, type SessionInfo, type SoftLogout, type Transport, type UploadExtras, type VerifyAction } from './transport';
55 import { statusForKind } from './wire-status';
6-import { pageTraceFilter, workerName } from './trace-name';
6+import { workerName } from './trace-name';
7+import { pageTraceFilter } from './trace-page';
78 import workerUrl from '../worker/worker.ts?worker&url';
89
910 function toApiError(error: { kind: string; message: string }): ApiError {
Modified file: web/src/lib/transport.ts+3 additions
@@ -40,6 +40,9 @@ export type CommandRequest =
4040 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
4141 | { op: 'set_room_notification_mode'; room_id: string; mode: 'AllMessages' | 'MentionsAndKeywordsOnly' | 'Mute' | null }
4242 | { op: 'notification_keywords' }
43+ | ReturnType<typeof import('./push-model').devicePusherRequest>
44+ | { op: 'remove_pusher'; app_id: string; pushkey: string }
45+ | { op: 'pushers' }
4346 | { op: 'create_key_backup' | 'enable_key_backup' }
4447 | { op: 'disable_key_backup'; delete_from_server?: boolean }
4548 | { op: 'recover_key_backup'; recovery_key: string }
Added file: web/src/sw-push.ts+105 additions
@@ -0,0 +1,105 @@
1+import { PUSH_CHANGED_MESSAGE, PUSH_CHANNEL, parsePush, parseResolved, shouldSilence, subscriptionKeys, type PushDisplay } from './lib/push-model';
2+
3+const RESOLVE_TIMEOUT_MS = 3000;
4+
5+declare const __MALICE_DEVICE__: boolean;
6+
7+interface BadgeNavigator {
8+ setAppBadge?: (n: number) => Promise<void>;
9+ clearAppBadge?: () => Promise<void>;
10+}
11+
12+let channel: BroadcastChannel | undefined;
13+let nextRequestId = 0;
14+const pending = new Map<string, (reply: { json?: unknown; error?: unknown }) => void>();
15+
16+function resolveChannel(): BroadcastChannel {
17+ if (!channel) {
18+ channel = new BroadcastChannel(PUSH_CHANNEL);
19+ channel.onmessage = (ev: MessageEvent) => {
20+ const msg = ev.data as { id?: unknown; json?: unknown; error?: unknown } | null;
21+ if (typeof msg?.id !== 'string') return;
22+ const done = pending.get(msg.id);
23+ if (!done) return;
24+ pending.delete(msg.id);
25+ done(msg);
26+ };
27+ }
28+ return channel;
29+}
30+
31+function requestResolve(roomId: string, eventId: string): Promise<unknown | null> {
32+ const id = `${Date.now()}-${nextRequestId++}`;
33+ return new Promise((resolve) => {
34+ const timer = setTimeout(() => {
35+ pending.delete(id);
36+ resolve(null);
37+ }, RESOLVE_TIMEOUT_MS);
38+ pending.set(id, (reply) => {
39+ clearTimeout(timer);
40+ resolve(reply.error ? null : (reply.json ?? null));
41+ });
42+ resolveChannel().postMessage({ id, kind: 'resolve', room_id: roomId, event_id: eventId });
43+ });
44+}
45+
46+function show(sw: ServiceWorkerGlobalScope, display: PushDisplay, title: string, body: string, silent: boolean): Promise<void> {
47+ const options: NotificationOptions & { renotify?: boolean } = {
48+ body,
49+ tag: display.tag,
50+ silent,
51+ renotify: display.mention && !silent,
52+ data: { roomId: display.roomId, navigate: display.navigate, eventId: display.eventId }
53+ };
54+ return sw.registration.showNotification(title, options);
55+}
56+
57+function setBadge(sw: ServiceWorkerGlobalScope, count: number | null): void {
58+ if (count === null) return;
59+ const nav = sw.navigator as unknown as BadgeNavigator;
60+ const call = count > 0 ? nav.setAppBadge?.(count) : nav.clearAppBadge?.();
61+ void call?.catch(() => undefined);
62+}
63+
64+async function handle(sw: ServiceWorkerGlobalScope, event: PushEvent): Promise<void> {
65+ const display = parsePush(event.data?.text() ?? null, sw.location.origin);
66+ const windows = await sw.clients.matchAll({ type: 'window', includeUncontrolled: true });
67+ const focused = windows.some((client) => client.focused && client.visibilityState === 'visible');
68+ await show(sw, display, display.title, display.body, shouldSilence(display, focused));
69+ setBadge(sw, display.badge);
70+ if (!__MALICE_DEVICE__ || windows.length === 0 || !display.roomId || !display.eventId) return;
71+ const resolved = parseResolved(await requestResolve(display.roomId, display.eventId), display);
72+ if (resolved) await show(sw, display, resolved.title, resolved.body, true);
73+}
74+
75+/** Browsers revoke the subscription when a push shows nothing, so every failure still shows a generic notification. */
76+export function onPush(sw: ServiceWorkerGlobalScope, event: PushEvent): void {
77+ event.waitUntil(
78+ handle(sw, event).catch(() =>
79+ sw.registration.showNotification('New message', { tag: 'malice', data: {} }).catch(() => undefined)
80+ )
81+ );
82+}
83+
84+export function onSubscriptionChange(sw: ServiceWorkerGlobalScope, event: Event): void {
85+ const change = event as Event & { oldSubscription?: PushSubscription | null; newSubscription?: PushSubscription | null };
86+ (event as ExtendableEvent).waitUntil(
87+ (async () => {
88+ let sub = change.newSubscription ?? (await sw.registration.pushManager.getSubscription());
89+ const key = change.oldSubscription?.options.applicationServerKey;
90+ if (!sub && key) sub = await sw.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
91+ const keys = sub ? subscriptionKeys(sub) : null;
92+ if (keys && !__MALICE_DEVICE__) {
93+ await fetch('/api/push/subscribe', {
94+ method: 'POST',
95+ credentials: 'same-origin',
96+ headers: { 'content-type': 'application/json' },
97+ body: JSON.stringify({ endpoint: keys.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } })
98+ }).catch(() => undefined);
99+ }
100+ for (const client of await sw.clients.matchAll({ type: 'window', includeUncontrolled: true })) {
101+ client.postMessage({ type: PUSH_CHANGED_MESSAGE });
102+ }
103+ })().catch(() => undefined)
104+ );
105+}
Modified file: web/src/sw.ts+10 additions−4 deletions
@@ -1,4 +1,6 @@
11 import { AVATAR_CACHE, handleMediaFetch } from './sw-media';
2+import { onPush, onSubscriptionChange } from './sw-push';
3+import { roomFromTag, roomTarget } from './lib/push-model';
24
35 declare const __MALICE_DEVICE__: boolean;
46
@@ -72,19 +74,23 @@ sw.addEventListener('fetch', (event) => {
7274 }
7375 });
7476
77+sw.addEventListener('push', (event) => onPush(sw, event));
78+sw.addEventListener('pushsubscriptionchange', (event) => onSubscriptionChange(sw, event));
79+
7580 sw.addEventListener('notificationclick', (event) => {
7681 event.notification.close();
77- const roomId = (event.notification.data as { roomId?: unknown } | null)?.roomId;
78- if (typeof roomId !== 'string') return;
82+ const data = event.notification.data as { roomId?: unknown; navigate?: unknown } | null;
83+ const roomId = typeof data?.roomId === 'string' ? data.roomId : roomFromTag(event.notification.tag);
84+ const navigate = typeof data?.navigate === 'string' ? data.navigate : null;
7985 event.waitUntil(
8086 (async () => {
8187 const windows = await sw.clients.matchAll({ type: 'window', includeUncontrolled: true });
8288 const existing = windows.find((client) => client.visibilityState === 'visible') ?? windows[0];
8389 if (!existing) {
84- await sw.clients.openWindow(`/?room=${encodeURIComponent(roomId)}`);
90+ await sw.clients.openWindow(roomTarget(roomId, navigate));
8591 return;
8692 }
87- existing.postMessage({ type: 'malice-open-room', roomId });
93+ if (roomId) existing.postMessage({ type: 'malice-open-room', roomId });
8894 await existing.focus();
8995 })()
9096 );
Modified file: web/src/worker/worker.ts+22 additions
@@ -1,6 +1,7 @@
11 import init, { Host, init_tracing } from './pkg/malice_worker.js';
22 import { isRecord } from '../lib/guards';
33 import { traceFilter } from '../lib/trace-name';
4+import { PUSH_CHANNEL } from '../lib/push-model';
45 import { EventCoalescer } from './coalesce';
56 import {
67 createAccount,
@@ -326,3 +327,24 @@ async function handleMediaRequest(msg: unknown): Promise<void> {
326327 mediaChannel.postMessage({ id, error: toWireError(error) });
327328 }
328329 }
330+
331+const pushChannel = new BroadcastChannel(PUSH_CHANNEL);
332+pushChannel.onmessage = (ev: MessageEvent) => void handlePushRequest(ev.data);
333+
334+async function handlePushRequest(msg: unknown): Promise<void> {
335+ await ready;
336+ if (!isRecord(msg) || typeof msg['id'] !== 'string' || msg['kind'] !== 'resolve') return;
337+ const id = msg['id'];
338+ const account = currentAccountKey();
339+ if (!account || typeof msg['room_id'] !== 'string' || typeof msg['event_id'] !== 'string') {
340+ pushChannel.postMessage({ id, error: { kind: 'not_found', message: 'cannot resolve push' } });
341+ return;
342+ }
343+ try {
344+ const request = { op: 'resolve_push_notification', room_id: msg['room_id'], event_id: msg['event_id'], origin: location.origin };
345+ const result = await host.call(account, JSON.stringify(request));
346+ pushChannel.postMessage({ id, json: result.json ?? null });
347+ } catch (error) {
348+ pushChannel.postMessage({ id, error: toWireError(error) });
349+ }
350+}
Added file: web/test/push-model.test.ts+132 additions
@@ -0,0 +1,132 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import {
4+ bytesToUrlBase64,
5+ devicePusherRequest,
6+ parsePush,
7+ parseResolved,
8+ parseStoredPush,
9+ roomFromTag,
10+ roomTarget,
11+ shouldSilence,
12+ stalePushers,
13+ syncAction,
14+ urlBase64ToBytes
15+} from '../src/lib/push-model.ts';
16+
17+const ORIGIN = 'https://chat.example';
18+
19+const FULL = {
20+ web_push: 8030,
21+ notification: {
22+ title: 'Ada in General',
23+ body: 'hello',
24+ navigate: `${ORIGIN}/?room=%21r%3Ax`,
25+ tag: 'room:!r:x',
26+ silent: false,
27+ app_badge: '4'
28+ },
29+ malice: { room_id: '!r:x', event_id: '$e', encrypted: true, mention: true, unread: 7 }
30+};
31+
32+test('parsePush reads the full contract', () => {
33+ const d = parsePush(JSON.stringify(FULL), ORIGIN);
34+ assert.equal(d.title, 'Ada in General');
35+ assert.equal(d.body, 'hello');
36+ assert.equal(d.tag, 'room:!r:x');
37+ assert.equal(d.navigate, `${ORIGIN}/?room=%21r%3Ax`);
38+ assert.equal(d.badge, 4);
39+ assert.equal(d.roomId, '!r:x');
40+ assert.equal(d.eventId, '$e');
41+ assert.equal(d.mention, true);
42+ assert.equal(d.encrypted, true);
43+});
44+
45+test('parsePush falls back for garbage, empty and missing data', () => {
46+ for (const raw of [null, '', 'not json', '[]', '"str"', '{}', 42]) {
47+ const d = parsePush(raw, ORIGIN);
48+ assert.equal(d.title, 'New message');
49+ assert.equal(d.body, '');
50+ assert.equal(d.tag, 'malice');
51+ assert.equal(d.roomId, null);
52+ assert.equal(d.badge, null);
53+ }
54+});
55+
56+test('parsePush derives the tag and badge from the malice block', () => {
57+ const d = parsePush({ malice: { room_id: '!a:b', unread: 3 } }, ORIGIN);
58+ assert.equal(d.tag, 'room:!a:b');
59+ assert.equal(d.badge, 3);
60+ assert.equal(parsePush({ notification: { app_badge: '0' } }, ORIGIN).badge, 0);
61+ assert.equal(parsePush({ notification: { app_badge: '-2' } }, ORIGIN).badge, null);
62+});
63+
64+test('parsePush drops navigate urls from other origins', () => {
65+ const d = parsePush({ notification: { navigate: 'https://evil.example/?room=x' } }, ORIGIN);
66+ assert.equal(d.navigate, null);
67+ assert.equal(parsePush({ notification: { navigate: '/?room=x' } }, ORIGIN).navigate, `${ORIGIN}/?room=x`);
68+});
69+
70+test('roomTarget prefers navigate then the room then the root', () => {
71+ assert.equal(roomTarget('!r:x', `${ORIGIN}/?room=y`), `${ORIGIN}/?room=y`);
72+ assert.equal(roomTarget('!r:x', null), '/?room=!r%3Ax');
73+ assert.equal(roomTarget(null, null), '/');
74+ assert.equal(roomFromTag('room:!r:x'), '!r:x');
75+ assert.equal(roomFromTag('malice'), null);
76+});
77+
78+test('shouldSilence keeps the banner but mutes it for a focused window or a silent payload', () => {
79+ const loud = parsePush(FULL, ORIGIN);
80+ assert.equal(shouldSilence(loud, false), false);
81+ assert.equal(shouldSilence(loud, true), true);
82+ assert.equal(shouldSilence({ ...loud, silent: true }, false), true);
83+});
84+
85+test('parseResolved only replaces when the content differs', () => {
86+ const d = parsePush(FULL, ORIGIN);
87+ assert.deepEqual(parseResolved({ title: 'Ada', body: 'real text' }, d), { title: 'Ada', body: 'real text' });
88+ assert.deepEqual(parseResolved({ body: 'real text' }, d), { title: d.title, body: 'real text' });
89+ assert.equal(parseResolved({ title: d.title, body: d.body }, d), null);
90+ assert.equal(parseResolved(null, d), null);
91+ assert.equal(parseResolved({}, d), null);
92+});
93+
94+test('syncAction repairs a lost or rotated subscription', () => {
95+ const stored = { endpoint: 'https://push/a', pushkey: 'k' };
96+ assert.equal(syncAction(false, null, stored), 'none');
97+ assert.equal(syncAction(true, null, stored), 'subscribe');
98+ assert.equal(syncAction(true, 'https://push/b', stored), 'register');
99+ assert.equal(syncAction(true, 'https://push/a', null), 'register');
100+ assert.equal(syncAction(true, 'https://push/a', stored), 'none');
101+});
102+
103+test('base64url round trips without padding', () => {
104+ const bytes = new Uint8Array([251, 255, 190, 1, 2]);
105+ const text = bytesToUrlBase64(bytes);
106+ assert.equal(text, '-_--AQI');
107+ assert.deepEqual([...urlBase64ToBytes(text)], [...bytes]);
108+});
109+
110+test('parseStoredPush tolerates junk', () => {
111+ assert.equal(parseStoredPush(null), null);
112+ assert.equal(parseStoredPush('{'), null);
113+ assert.deepEqual(parseStoredPush('{"endpoint":"e","pushkey":"k"}'), { endpoint: 'e', pushkey: 'k' });
114+ assert.deepEqual(parseStoredPush('{"endpoint":"e"}'), { endpoint: 'e', pushkey: null });
115+});
116+
117+test('devicePusherRequest targets the same-origin gateway', () => {
118+ const r = devicePusherRequest({ endpoint: 'https://push/a', p256dh: 'pk', auth: 'au' }, ORIGIN, 'en', 'b1');
119+ assert.equal(r.op, 'set_pusher');
120+ assert.equal(r.app_id, 'dev.malice.device');
121+ assert.equal(r.pushkey, 'pk');
122+ assert.equal(r.url, `${ORIGIN}/_matrix/push/v1/notify`);
123+ assert.equal(r.format, 'Full');
124+ assert.deepEqual(r.data, { endpoint: 'https://push/a', auth: 'au', browser: 'b1' });
125+});
126+
127+test('stalePushers only picks this browser own old endpoints', () => {
128+ const mk = (pushkey: string, endpoint: string, browser: string, app_id = 'dev.malice.device') => ({ app_id, pushkey, data: { endpoint, browser } });
129+ const reply = { pushers: [mk('a', 'old', 'b1'), mk('b', 'cur', 'b1'), mk('c', 'old', 'b2'), mk('d', 'old', 'b1', 'other.app')] };
130+ assert.deepEqual(stalePushers(reply, 'b1', 'cur'), ['a']);
131+ assert.deepEqual(stalePushers(null, 'b1', 'cur'), []);
132+});
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", "src/sw-media.ts"]
13+ "exclude": ["src/worker/**", "src/sw.ts", "src/sw-media.ts", "src/sw-push.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/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"]
14+ "include": ["src/worker/**/*.ts", "src/sw.ts", "src/sw-media.ts", "src/sw-push.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 }