| 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 | } |