Commit 235ce394f90d
tarball14 files changed+655-17
@@ -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');@@ -13,6 +13,8 @@ import {
1313
type NotificationPlan1414
} 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);@@ -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'] === true87
+ };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
+}@@ -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 StoredPush13
+} 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
+}@@ -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
-}@@ -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
+}@@ -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 {@@ -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 }@@ -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
+}@@ -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
);@@ -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
+}@@ -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
+ urlBase64ToBytes15
+} 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
+});@@ -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
}@@ -11,5 +11,5 @@
1111
"types": ["vite/client"],1212
"noEmit": true1313
},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
}