From 64a2656ad7a337fa17c4cc61f52536ea1a25afac Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 19:58:40 +0000 Subject: [PATCH 1/2] pwa: installable manifest fixes and Web Push stage 0-1 (worker handlers, session in IndexedDB, settings toggle, dark config block) Stage 0: manifest gains id "/" (what Chrome already derives from start_url), scope, a 512 maskable icon, and drops the portrait lock. The service worker keeps the session ({baseUrl, accessToken}) in IndexedDB `ratatoskr-sw` so a push can wake a worker with no tab; the page's existing logout message deletes it. Stage 1: `push` and `notificationclick` handlers (src/app/features/push), a pusher toggle in notification settings, `push` block in config.json shipped empty (off). Tested against a mock homeserver and Matrix gateway body. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 5 + public/manifest.json | 9 +- .../android-chrome-maskable-512x512.png | Bin 0 -> 20178 bytes src/app/features/push/PushNotification.tsx | 69 +++++++++++ src/app/features/push/config.test.ts | 24 ++++ src/app/features/push/config.ts | 12 ++ src/app/features/push/manifest.test.ts | 29 +++++ src/app/features/push/payload.test.ts | 108 ++++++++++++++++++ src/app/features/push/payload.ts | 95 +++++++++++++++ src/app/features/push/pusher.test.ts | 94 +++++++++++++++ src/app/features/push/pusher.ts | 86 ++++++++++++++ src/app/features/push/sessionStore.test.ts | 96 ++++++++++++++++ src/app/features/push/sessionStore.ts | 78 +++++++++++++ src/app/features/push/testkit.ts | 41 +++++++ src/app/features/push/usePushOpen.ts | 33 ++++++ src/app/features/push/worker.test.ts | 103 +++++++++++++++++ src/app/features/push/worker.ts | 61 ++++++++++ .../notifications/SystemNotification.tsx | 14 +++ src/app/hooks/useClientConfig.ts | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/sw.ts | 33 ++++++ 21 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 public/res/android/android-chrome-maskable-512x512.png create mode 100644 src/app/features/push/PushNotification.tsx create mode 100644 src/app/features/push/config.test.ts create mode 100644 src/app/features/push/config.ts create mode 100644 src/app/features/push/manifest.test.ts create mode 100644 src/app/features/push/payload.test.ts create mode 100644 src/app/features/push/payload.ts create mode 100644 src/app/features/push/pusher.test.ts create mode 100644 src/app/features/push/pusher.ts create mode 100644 src/app/features/push/sessionStore.test.ts create mode 100644 src/app/features/push/sessionStore.ts create mode 100644 src/app/features/push/testkit.ts create mode 100644 src/app/features/push/usePushOpen.ts create mode 100644 src/app/features/push/worker.test.ts create mode 100644 src/app/features/push/worker.ts diff --git a/config.json b/config.json index f98575d9..fea9a59e 100644 --- a/config.json +++ b/config.json @@ -28,6 +28,11 @@ "fileZoneShare": { "enabled": false }, + "push": { + "vapidPublicKey": "", + "gatewayUrl": "", + "appId": "" + }, "registration": { "enabled": false, "url": "" diff --git a/public/manifest.json b/public/manifest.json index ef0a3510..c28e7b41 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,12 +1,13 @@ { "name": "Ratatoskr", "short_name": "Ratatoskr", + "id": "/", "description": "GameCult's Matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", - "orientation": "portrait", "start_url": "./", + "scope": "./", "background_color": "#07111a", "theme_color": "#07111a", "icons": [ @@ -54,6 +55,12 @@ "src": "./public/android/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "./public/android/android-chrome-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/public/res/android/android-chrome-maskable-512x512.png b/public/res/android/android-chrome-maskable-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..211a06505160a3fa1f6b165146d779db27c6714a GIT binary patch literal 20178 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 literal 0 HcmV?d00001 diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx new file mode 100644 index 00000000..bf81640c --- /dev/null +++ b/src/app/features/push/PushNotification.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Switch, Text, color } from 'folds'; +import { SettingTile } from '../../components/setting-tile'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; +import type { ActivePushConfig } from './config'; +import { disablePush, enablePush } from './pusher'; + +export const pushSupported = (): boolean => + 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; + +const readyManager = async () => (await navigator.serviceWorker.ready).pushManager; + +/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */ +export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { + const mx = useMatrixClient(); + const permission = usePermissionState('notifications', getNotificationState()); + const [on, setOn] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + readyManager() + .then((m) => m.getSubscription()) + .then((s) => setOn(!!s)) + .catch(() => undefined); + }, [cfg]); + + const toggle = useCallback( + async (enable: boolean) => { + setBusy(true); + setError(undefined); + try { + if (enable) { + if ((await window.Notification.requestPermission()) !== 'granted') { + throw new Error('Notification permission was not granted.'); + } + await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64)); + } else { + await disablePush(mx, await readyManager(), cfg); + } + setOn(enable); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not change background notifications.'); + } finally { + setBusy(false); + } + }, + [cfg, mx] + ); + + return ( + + {error ?? 'Notification permission is blocked. Allow it from the browser address bar.'} + + ) : ( + + Notify when the app is closed. Installing the app to the home screen helps on iOS. + + ) + } + after={} + /> + ); +} diff --git a/src/app/features/push/config.test.ts b/src/app/features/push/config.test.ts new file mode 100644 index 00000000..f014138a --- /dev/null +++ b/src/app/features/push/config.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { activePushConfig } from './config'; + +describe('activePushConfig', () => { + it('is off unless all three keys are filled', () => { + expect(activePushConfig(undefined)).toBeUndefined(); + expect(activePushConfig({})).toBeUndefined(); + expect( + activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' }) + ).toBeUndefined(); + expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({ + vapidPublicKey: 'k', + gatewayUrl: 'u', + appId: 'a', + }); + }); + it('the shipped config.json carries the block empty, so push is off', () => { + const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8')); + expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' }); + expect(activePushConfig(cfg.push)).toBeUndefined(); + }); +}); diff --git a/src/app/features/push/config.ts b/src/app/features/push/config.ts new file mode 100644 index 00000000..31e476cd --- /dev/null +++ b/src/app/features/push/config.ts @@ -0,0 +1,12 @@ +// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret; +// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`). +export type PushConfig = { + vapidPublicKey?: string; + gatewayUrl?: string; + appId?: string; +}; + +export type ActivePushConfig = Required; + +export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined => + c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined; diff --git a/src/app/features/push/manifest.test.ts b/src/app/features/push/manifest.test.ts new file mode 100644 index 00000000..806b3fc4 --- /dev/null +++ b/src/app/features/push/manifest.test.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(__dirname, '../../../..'); +const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8')); + +describe('web app manifest', () => { + it('has a stable identity and scope, and no orientation lock', () => { + expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root + expect(manifest.scope).toBe('./'); + expect(manifest.start_url).toBe('./'); + expect(manifest.display).toBe('standalone'); + expect(manifest.orientation).toBeUndefined(); + }); + it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => { + const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[]; + const any = icons.filter((i) => !i.purpose); + expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512'])); + const maskable = icons.filter((i) => i.purpose === 'maskable'); + expect(maskable.map((i) => i.sizes)).toEqual(['512x512']); + // the build copies public/res/android to public/android (vite.config.js) + icons.forEach((i) => + expect( + existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/'))) + ).toBe(true) + ); + }); +}); diff --git a/src/app/features/push/payload.test.ts b/src/app/features/push/payload.test.ts new file mode 100644 index 00000000..8e9e04eb --- /dev/null +++ b/src/app/features/push/payload.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { describePush, GENERIC, parsePush } from './payload'; +import { matrixNotify, mockHomeserver, SESSION } from './testkit'; + +describe('parsePush', () => { + it('reads the Matrix gateway body and the bare inner object', () => { + const a = parsePush(matrixNotify('!r:x', '$e')); + expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' }); + expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' }); + }); + it('rejects a body with no room id', () => { + expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined(); + expect(parsePush(undefined)).toBeUndefined(); + expect(parsePush('x')).toBeUndefined(); + }); + it('carries text the gateway added', () => { + expect( + parsePush({ + notification: { + room_id: '!r:x', + room_name: 'Lobby', + sender_display_name: 'Ann', + content: { body: 'hi' }, + }, + }) + ).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' }); + }); +}); + +describe('describePush', () => { + const ids = { roomId: '!r:x', eventId: '$e' }; + const event = { + type: 'm.room.message', + sender: '@ann:x', + content: { msgtype: 'm.text', body: 'hello there' }, + }; + + it('resolves ids through the homeserver with the stored token', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: event }, + roomNames: { '!r:x': 'Lobby' }, + }); + expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({ + title: 'Lobby', + body: 'ann: hello there', + }); + expect(hs.seen).toHaveLength(2); + expect( + hs.seen.every( + (r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/') + ) + ).toBe(true); + expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/'); + }); + it('shows the generic notification with no session, and asks nobody', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({ + title: 'Ratatoskr', + body: 'New message', + }); + expect(hs.seen).toHaveLength(0); + }); + it('falls back when the token is refused, the network is down, or the event is encrypted', async () => { + const stale = mockHomeserver({ token: 'other', events: { $e: event } }); + expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC); + const down = mockHomeserver({ token: 'tok-1', failAll: true }); + expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC); + const enc = mockHomeserver({ + token: 'tok-1', + events: { + $e: { + type: 'm.room.encrypted', + sender: '@ann:x', + content: { algorithm: 'm.megolm.v1.aes-sha2' }, + }, + }, + roomNames: { '!r:x': 'Lobby' }, + }); + expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({ + title: 'Lobby', + body: 'New message', + }); + }); + it('does not fetch what the push already carried', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + const shown = await describePush( + { ...ids, roomName: 'R', sender: 'Bo', text: 'yo' }, + SESSION, + hs.fetchFn + ); + expect(shown).toEqual({ title: 'R', body: 'Bo: yo' }); + expect(hs.seen).toHaveLength(0); + }); + it('clips a long body', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: { ...event, content: { body: 'x'.repeat(500) } } }, + }); + const shown = await describePush(ids, SESSION, hs.fetchFn); + expect(shown.body.length).toBeLessThanOrEqual(200); + expect(shown.body.endsWith('…')).toBe(true); + }); + it('shows the generic notification when the push names no room', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC); + }); +}); diff --git a/src/app/features/push/payload.ts b/src/app/features/push/payload.ts new file mode 100644 index 00000000..b722d92a --- /dev/null +++ b/src/app/features/push/payload.ts @@ -0,0 +1,95 @@ +import type { SessionRecord } from './sessionStore'; + +/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */ +export interface PushIds { + roomId: string; + eventId?: string; + roomName?: string; + sender?: string; + text?: string; +} + +export interface Shown { + title: string; + body: string; +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + +/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */ +export function parsePush(raw: unknown): PushIds | undefined { + const top = (raw ?? {}) as Record; + const n = (top.notification ?? top) as Record; + const roomId = str(n.room_id); + if (!roomId) return undefined; + const content = (n.content ?? {}) as Record; + return { + roomId, + eventId: str(n.event_id), + roomName: str(n.room_name), + sender: str(n.sender_display_name) ?? str(n.sender), + text: str(content.body), + }; +} + +export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' }; +const MAX_BODY = 200; +const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s); +const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0]; + +type Fetch = (url: string, init: RequestInit) => Promise; + +async function getJson( + f: Fetch, + session: SessionRecord, + path: string, + timeoutMs: number +): Promise | undefined> { + try { + const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok ? ((await res.json()) as Record) : undefined; + } catch { + return undefined; + } +} + +/** + * Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are + * fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification: + * a push must always show something. + */ +export async function describePush( + ids: PushIds | undefined, + session: SessionRecord | undefined, + f: Fetch, + timeoutMs = 4000 +): Promise { + if (!ids) return GENERIC; + let { roomName, sender, text } = ids; + if (session && (!text || !roomName)) { + const room = encodeURIComponent(ids.roomId); + const [event, name] = await Promise.all([ + !text && ids.eventId + ? getJson( + f, + session, + `/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`, + timeoutMs + ) + : undefined, + !roomName + ? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs) + : undefined, + ]); + const content = (event?.content ?? {}) as Record; + if (event?.type === 'm.room.message') text = str(content.body); + if (!sender && str(event?.sender)) sender = localpart(event?.sender as string); + roomName = roomName ?? str(name?.name); + } + let { body } = GENERIC; + if (text) body = sender ? `${sender}: ${text}` : text; + return { title: roomName ?? GENERIC.title, body: clip(body) }; +} diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts new file mode 100644 index 00000000..73b270cb --- /dev/null +++ b/src/app/features/push/pusher.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { IPusherRequest } from 'matrix-js-sdk'; +import { disablePush, enablePush, PushManagerPort, vapidKeyBytes, webPushPusher } from './pusher'; + +const cfg = { + vapidPublicKey: 'BAUD', + gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify', + appId: 'org.gamecult.ratatoskr.web', +}; +const key = (...b: number[]) => Uint8Array.from(b).buffer; + +function subscription(unsubscribed: string[] = []): PushSubscription { + return { + endpoint: 'https://push.example/send/abc', + getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), + unsubscribe: async () => { + unsubscribed.push('x'); + return true; + }, + } as unknown as PushSubscription; +} + +function world() { + const calls: string[] = []; + const pushers: IPusherRequest[] = []; + let sub: PushSubscription | null = null; + const unsub: string[] = []; + const mx = { + setPusher: async (p: IPusherRequest) => { + calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); + pushers.push(p); + }, + }; + const push: PushManagerPort = { + getSubscription: async () => sub, + subscribe: async (o) => { + calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`); + sub = subscription(unsub); + return sub; + }, + }; + return { mx, push, calls, pushers, unsub }; +} + +describe('web push pusher', () => { + it('decodes the URL-safe VAPID key', () => { + expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); + }); + it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { + const p = webPushPusher(cfg, subscription(), 'phone') as unknown as Record & { + data: Record; + }; + expect(p).toMatchObject({ + kind: 'http', + app_id: cfg.appId, + pushkey: '-_8B', + device_display_name: 'phone', + append: false, + }); + expect(p.data).toMatchObject({ + url: cfg.gatewayUrl, + format: 'event_id_only', + endpoint: 'https://push.example/send/abc', + auth: '-gI', + p256dh: '-_8B', + }); + }); + it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => { + const w = world(); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']); + }); + it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => { + const w = world(); + await disablePush(w.mx, w.push, cfg); + expect(w.calls).toEqual([]); + await enablePush(w.mx, w.push, cfg, 'phone'); + await disablePush(w.mx, w.push, cfg); + expect(w.calls.slice(-1)).toEqual(['delete-pusher']); + expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' }); + expect(w.unsub).toEqual(['x']); + }); + it('a failed pusher write leaves the subscription alone', async () => { + const w = world(); + await enablePush(w.mx, w.push, cfg, 'phone'); + w.mx.setPusher = async () => { + throw new Error('homeserver said no'); + }; + await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); + expect(w.unsub).toEqual([]); + }); +}); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts new file mode 100644 index 00000000..65c0aca4 --- /dev/null +++ b/src/app/features/push/pusher.ts @@ -0,0 +1,86 @@ +import type { IPusherRequest } from 'matrix-js-sdk'; +import type { ActivePushConfig } from './config'; + +/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ +export interface PusherClient { + setPusher(p: IPusherRequest): Promise; +} +export interface PushManagerPort { + getSubscription(): Promise; + subscribe(o: { + userVisibleOnly: boolean; + applicationServerKey: Uint8Array; + }): Promise; +} + +export function vapidKeyBytes(b64url: string): Uint8Array { + const pad = '='.repeat((4 - (b64url.length % 4)) % 4); + const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +const b64url = (buf: ArrayBuffer | null): string => + btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0)))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +/** + * The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the + * pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which + * the worker resolves itself. + */ +export function webPushPusher( + cfg: ActivePushConfig, + sub: PushSubscription, + deviceName: string +): IPusherRequest { + const p256dh = b64url(sub.getKey('p256dh')); + return { + kind: 'http', + app_id: cfg.appId, + pushkey: p256dh, + app_display_name: 'Ratatoskr', + device_display_name: deviceName, + lang: navigator.language || 'en', + data: { + url: cfg.gatewayUrl, + format: 'event_id_only', + endpoint: sub.endpoint, + auth: b64url(sub.getKey('auth')), + p256dh, + }, + append: false, + } as IPusherRequest; +} + +export async function enablePush( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig, + deviceName: string +): Promise { + const sub = + (await push.getSubscription()) ?? + (await push.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidKeyBytes(cfg.vapidPublicKey), + })); + await mx.setPusher(webPushPusher(cfg, sub, deviceName)); +} + +/** Delete the pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ +export async function disablePush( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig +): Promise { + const sub = await push.getSubscription(); + if (!sub) return; + await mx.setPusher({ + kind: null, + app_id: cfg.appId, + pushkey: b64url(sub.getKey('p256dh')), + } as unknown as IPusherRequest); + await sub.unsubscribe(); +} diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts new file mode 100644 index 00000000..81668a5f --- /dev/null +++ b/src/app/features/push/sessionStore.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore'; + +const memory = (): RecordPort & { held?: unknown } => { + const m: RecordPort & { held?: unknown } = { + async read() { + return m.held; + }, + async write(v) { + m.held = v; + }, + }; + return m; +}; + +describe('sessionStore', () => { + it('saves a valid session and loads it back', async () => { + const s = sessionStore(memory()); + await s.save('https://hs/', 'tok'); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + }); + it('deletes the record on logout, and on anything that is not two strings', async () => { + const port = memory(); + const s = sessionStore(port); + await s.save('https://hs/', 'tok'); + await s.save(undefined, undefined); + expect(port.held).toBeUndefined(); + await s.save('https://hs/', 'tok'); + await s.save('https://hs/', 5); + expect(await s.load()).toBeUndefined(); + }); + it('ignores a record that is not a session', async () => { + const port = memory(); + port.held = { baseUrl: 1 }; + expect(await sessionStore(port).load()).toBeUndefined(); + }); +}); + +// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks. +function fakeIndexedDB(): IDBFactory { + const dbs = new Map>>(); + const request = (run: () => T) => { + const r = {} as IDBRequest; + setTimeout(() => { + (r as { result: T }).result = run(); + r.onsuccess?.(new Event('success')); + }, 0); + return r; + }; + return { + open(name: string) { + const r = {} as IDBOpenDBRequest; + setTimeout(() => { + const fresh = !dbs.has(name); + const stores = dbs.get(name) ?? new Map(); + dbs.set(name, stores); + const db = { + createObjectStore: (n: string) => stores.set(n, new Map()), + close: () => undefined, + transaction: (n: string) => { + const tx = {} as IDBTransaction; + const store = stores.get(n) as Map; + const api = { + get: (k: string) => request(() => store.get(k)), + put: (v: unknown, k: string) => { + store.set(k, v); + setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); + }, + delete: (k: string) => { + store.delete(k); + setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); + }, + }; + (tx as { objectStore: unknown }).objectStore = () => api; + return tx; + }, + } as unknown as IDBDatabase; + (r as { result: IDBDatabase }).result = db; + if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent); + r.onsuccess?.(new Event('success')); + }, 0); + return r; + }, + } as unknown as IDBFactory; +} + +describe('idbPort', () => { + it('round-trips the one record through IndexedDB and deletes it', async () => { + const s = sessionStore(idbPort(fakeIndexedDB())); + expect(await s.load()).toBeUndefined(); + await s.save('https://hs/', 'tok'); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + await s.save(undefined, undefined); + expect(await s.load()).toBeUndefined(); + }); +}); diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts new file mode 100644 index 00000000..adaa711b --- /dev/null +++ b/src/app/features/push/sessionStore.ts @@ -0,0 +1,78 @@ +// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. +// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other +// origins; the token is already in the page's own storage). Written when the page hands the worker its session, +// deleted when the page hands it none (logout). Nothing else is stored, and nothing else reads it but the push handler. +export interface SessionRecord { + baseUrl: string; + accessToken: string; +} + +/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */ +export interface RecordPort { + read(): Promise; + write(value: SessionRecord | undefined): Promise; +} + +const DB = 'ratatoskr-sw'; +const STORE = 'session'; +const KEY = 'session'; + +const done = (req: IDBRequest): Promise => + new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + +export function idbPort(factory: IDBFactory): RecordPort { + const open = (): Promise => { + const req = factory.open(DB, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + return done(req); + }; + return { + async read() { + const db = await open(); + try { + return await done(db.transaction(STORE).objectStore(STORE).get(KEY)); + } finally { + db.close(); + } + }, + async write(value) { + const db = await open(); + try { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + const finished = new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + if (value) store.put(value, KEY); + else store.delete(KEY); + await finished; + } finally { + db.close(); + } + }, + }; +} + +export function sessionStore(port: RecordPort) { + return { + /** A valid session replaces the record; anything else (logout) deletes it. */ + async save(baseUrl: unknown, accessToken: unknown): Promise { + await port.write( + typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken + ? { baseUrl, accessToken } + : undefined + ); + }, + async load(): Promise { + const v = (await port.read()) as Partial | undefined; + return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' + ? { baseUrl: v.baseUrl, accessToken: v.accessToken } + : undefined; + }, + }; +} diff --git a/src/app/features/push/testkit.ts b/src/app/features/push/testkit.ts new file mode 100644 index 00000000..7f594f9f --- /dev/null +++ b/src/app/features/push/testkit.ts @@ -0,0 +1,41 @@ +import type { SessionRecord } from './sessionStore'; + +/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */ +export function mockHomeserver(opts: { + token: string; + events?: Record; + roomNames?: Record; + failAll?: boolean; +}) { + const seen: { url: string; auth: string | null }[] = []; + const fetchFn = async (url: string, init: RequestInit): Promise => { + const auth = new Headers(init.headers).get('Authorization'); + seen.push({ url, auth }); + if (opts.failAll) throw new Error('network down'); + if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 }); + const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url); + if (ev) { + const e = opts.events?.[decodeURIComponent(ev[2])]; + return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 }); + } + const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url); + if (nm) { + const name = opts.roomNames?.[decodeURIComponent(nm[1])]; + return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 }); + } + return new Response('{}', { status: 404 }); + }; + return { fetchFn, seen }; +} + +export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' }; + +/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */ +export const matrixNotify = (roomId: string, eventId: string) => ({ + notification: { + event_id: eventId, + room_id: roomId, + counts: { unread: 1 }, + devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }], + }, +}); diff --git a/src/app/features/push/usePushOpen.ts b/src/app/features/push/usePushOpen.ts new file mode 100644 index 00000000..e3b88d3a --- /dev/null +++ b/src/app/features/push/usePushOpen.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker'; + +/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */ +export function usePushOpen(): void { + const { navigateRoom } = useRoomNavigate(); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const roomId = params.get(PUSH_ROOM_PARAM); + if (roomId) { + navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined); + params.delete(PUSH_ROOM_PARAM); + params.delete(PUSH_EVENT_PARAM); + const rest = params.toString(); + window.history.replaceState( + null, + '', + `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}` + ); + } + const onMessage = (ev: MessageEvent) => { + if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') { + navigateRoom( + ev.data.roomId, + typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined + ); + } + }; + navigator.serviceWorker?.addEventListener('message', onMessage); + return () => navigator.serviceWorker?.removeEventListener('message', onMessage); + }, [navigateRoom]); +} diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts new file mode 100644 index 00000000..f1f766ce --- /dev/null +++ b/src/app/features/push/worker.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker'; +import { matrixNotify, mockHomeserver, SESSION } from './testkit'; + +type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } }; + +function port(over: Partial = {}) { + const shown: Shown[] = []; + const opened: string[] = []; + const w: WorkerPort = { + windows: async () => [], + openWindow: async (u) => { + opened.push(u); + }, + show: async (title, options) => { + shown.push({ title, options }); + }, + loadSession: async () => SESSION, + fetch: mockHomeserver({ token: 'tok-1' }).fetchFn, + scope: 'https://chat.example/', + ...over, + }; + return { w, shown, opened }; +} + +const msg = { + type: 'm.room.message', + sender: '@ann:x', + content: { msgtype: 'm.text', body: 'hello' }, +}; + +describe('push against a mock gateway and homeserver', () => { + it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: msg }, + roomNames: { '!r:x': 'Lobby' }, + }); + const { w, shown } = port({ fetch: hs.fetchFn }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown).toHaveLength(1); + expect(shown[0].title).toBe('Lobby'); + expect(shown[0].options.body).toBe('ann: hello'); + expect(shown[0].options.tag).toBe('!r:x'); + expect(shown[0].options.renotify).toBe(true); + expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' }); + }); + it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => { + const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } }); + const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } }); + expect(hs.seen).toHaveLength(0); + }); + it('shows the generic notification for a body it cannot read', async () => { + const { w, shown } = port(); + await handlePush(undefined, w); + expect(shown[0].title).toBe('Ratatoskr'); + expect(shown[0].options.tag).toBeUndefined(); + expect(shown[0].options.data).toBeUndefined(); + }); + it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { + const win = (visibilityState: string, focused: boolean) => ({ + visibilityState, + focused, + focus: async () => undefined, + postMessage: () => undefined, + }); + const a = port({ windows: async () => [win('visible', true)] }); + await handlePush(matrixNotify('!r:x', '$e'), a.w); + expect(a.shown).toHaveLength(0); + const b = port({ windows: async () => [win('hidden', false), win('visible', false)] }); + await handlePush(matrixNotify('!r:x', '$e'), b.w); + expect(b.shown).toHaveLength(1); + }); +}); + +describe('notification click', () => { + it('focuses the open window and tells it the room', async () => { + const focus = vi.fn(async () => undefined); + const postMessage = vi.fn(); + const { w, opened } = port({ + windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }], + }); + await handleClick({ roomId: '!r:x', eventId: '$e' }, w); + expect(focus).toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' }); + expect(opened).toHaveLength(0); + }); + it('with no window, starts the app with the room in the URL', async () => { + const { w, opened } = port(); + await handleClick({ roomId: '!r:x', eventId: '$e' }, w); + const u = new URL(opened[0]); + expect(u.origin + u.pathname).toBe('https://chat.example/'); + expect(u.searchParams.get('pushRoom')).toBe('!r:x'); + expect(u.searchParams.get('pushEvent')).toBe('$e'); + }); + it('with no window and no target, just opens the app', async () => { + const { w, opened } = port(); + await handleClick(undefined, w); + expect(opened).toEqual(['https://chat.example/']); + }); +}); diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts new file mode 100644 index 00000000..5e3ccd75 --- /dev/null +++ b/src/app/features/push/worker.ts @@ -0,0 +1,61 @@ +import { describePush, parsePush } from './payload'; +import type { SessionRecord } from './sessionStore'; + +/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */ +export interface WorkerPort { + windows(): Promise< + { + visibilityState: string; + focused: boolean; + focus(): Promise; + postMessage(m: unknown): void; + }[] + >; + openWindow(url: string): Promise; + show(title: string, options: NotificationOptions): Promise; + loadSession(): Promise; + fetch(url: string, init: RequestInit): Promise; + /** The worker's scope URL: where a cold click opens the app. */ + scope: string; +} + +export interface PushTarget { + roomId: string; + eventId?: string; +} + +/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ +export async function handlePush(raw: unknown, w: WorkerPort): Promise { + const ids = parsePush(raw); + if ((await w.windows()).some((c) => c.visibilityState === 'visible' && c.focused)) return; + const shown = await describePush(ids, await w.loadSession(), w.fetch); + await w.show(shown.title, { + body: shown.body, + // One notification per room, replaced (and re-alerted) by the next message there. + tag: ids?.roomId, + renotify: !!ids?.roomId, + icon: './public/android/android-chrome-192x192.png', + data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined, + } as NotificationOptions); +} + +export const OPEN_ROOM = 'openRoom'; +export const PUSH_ROOM_PARAM = 'pushRoom'; +export const PUSH_EVENT_PARAM = 'pushEvent'; + +/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */ +export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise { + const windows = await w.windows(); + const open = windows[0]; + if (open) { + await open.focus(); + if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target }); + return; + } + const url = new URL(w.scope); + if (target?.roomId) { + url.searchParams.set(PUSH_ROOM_PARAM, target.roomId); + if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId); + } + await w.openWindow(url.href); +} diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 38115980..90630e1c 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -10,6 +10,9 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PushNotification, pushSupported } from '../../push/PushNotification'; +import { activePushConfig } from '../../push/config'; +import { useClientConfig } from '../../../hooks/useClientConfig'; function EmailNotification() { const mx = useMatrixClient(); @@ -85,6 +88,7 @@ function EmailNotification() { } export function SystemNotification() { + const pushCfg = activePushConfig(useClientConfig().push); const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( @@ -133,6 +137,16 @@ export function SystemNotification() { } /> + {pushCfg && pushSupported() && ( + + + + )} diff --git a/src/sw.ts b/src/sw.ts index 69293b1d..4df85a88 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,8 +1,14 @@ /// +import { handleClick, handlePush, PushTarget, WorkerPort } from './app/features/push/worker'; +import { idbPort, sessionStore } from './app/features/push/sessionStore'; + export type {}; declare const self: ServiceWorkerGlobalScope; +// What a push can do with no tab open: the session is kept in IndexedDB (sessionStore.ts), not only in `sessions` below. +const persisted = sessionStore(idbPort(self.indexedDB)); + type SessionInfo = { accessToken: string; baseUrl: string; @@ -30,6 +36,7 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { + persisted.save(baseUrl, accessToken).catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { @@ -156,3 +163,29 @@ self.addEventListener('fetch', (event: FetchEvent) => { }) ); }); + +const workerPort: WorkerPort = { + windows: async () => [ + ...(await self.clients.matchAll({ type: 'window', includeUncontrolled: true })), + ], + openWindow: (url) => self.clients.openWindow(url), + show: (title, options) => self.registration.showNotification(title, options), + loadSession: () => persisted.load().catch(() => undefined), + fetch: (url, init) => fetch(url, init), + scope: self.registration.scope, +}; + +self.addEventListener('push', (event: PushEvent) => { + let raw: unknown; + try { + raw = event.data?.json(); + } catch { + raw = undefined; + } + event.waitUntil(handlePush(raw, workerPort)); +}); + +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close(); + event.waitUntil(handleClick(event.notification.data as PushTarget | undefined, workerPort)); +}); -- 2.47.3 From 695a016d4fa7a93b7a7544b72cbffb822b48c8b0 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 20:05:10 +0000 Subject: [PATCH 2/2] push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription Soul pass findings on the stage 0-1 branch: the worker stored the token on every deployment, push configured or not (now only while the browser holds a push subscription; the page re-sends its session after the toggle changes it); the toggle read the browser's subscription, not the account's pusher (now both); a subscription under an old VAPID key was reused (now replaced); a failing client list now still shows the notification. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/push/PushNotification.tsx | 11 +++-- src/app/features/push/pusher.test.ts | 57 ++++++++++++++++++++-- src/app/features/push/pusher.ts | 35 ++++++++++--- src/app/features/push/sessionStore.test.ts | 15 ++++++ src/app/features/push/sessionStore.ts | 14 +++++- src/app/features/push/worker.test.ts | 5 ++ src/app/features/push/worker.ts | 3 +- src/sw.ts | 8 ++- 8 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx index bf81640c..a818354e 100644 --- a/src/app/features/push/PushNotification.tsx +++ b/src/app/features/push/PushNotification.tsx @@ -4,7 +4,8 @@ import { SettingTile } from '../../components/setting-tile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; import type { ActivePushConfig } from './config'; -import { disablePush, enablePush } from './pusher'; +import { pushSessionToSW } from '../../../sw-session'; +import { disablePush, enablePush, pushIsOn } from './pusher'; export const pushSupported = (): boolean => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; @@ -21,10 +22,10 @@ export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { useEffect(() => { readyManager() - .then((m) => m.getSubscription()) - .then((s) => setOn(!!s)) + .then((m) => pushIsOn(mx, m, cfg)) + .then(setOn) .catch(() => undefined); - }, [cfg]); + }, [cfg, mx]); const toggle = useCallback( async (enable: boolean) => { @@ -39,6 +40,8 @@ export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { } else { await disablePush(mx, await readyManager(), cfg); } + // The worker keeps the token only while a subscription exists: tell it the new state. + pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined); setOn(enable); } catch (e) { setError(e instanceof Error ? e.message : 'Could not change background notifications.'); diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts index 73b270cb..ffefa1f4 100644 --- a/src/app/features/push/pusher.test.ts +++ b/src/app/features/push/pusher.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { IPusherRequest } from 'matrix-js-sdk'; -import { disablePush, enablePush, PushManagerPort, vapidKeyBytes, webPushPusher } from './pusher'; +import { + disablePush, + enablePush, + PushManagerPort, + pushIsOn, + vapidKeyBytes, + webPushPusher, +} from './pusher'; const cfg = { vapidPublicKey: 'BAUD', @@ -9,8 +16,9 @@ const cfg = { }; const key = (...b: number[]) => Uint8Array.from(b).buffer; -function subscription(unsubscribed: string[] = []): PushSubscription { +function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription { return { + options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer }, endpoint: 'https://push.example/send/abc', getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), unsubscribe: async () => { @@ -30,6 +38,16 @@ function world() { calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); pushers.push(p); }, + // The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions. + getPushers: async () => ({ + pushers: pushers + .filter( + (p, i) => + p.kind !== null && + !pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey) + ) + .map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })), + }), }; const push: PushManagerPort = { getSubscription: async () => sub, @@ -39,7 +57,10 @@ function world() { return sub; }, }; - return { mx, push, calls, pushers, unsub }; + const swapSub = (s: PushSubscription | null) => { + sub = s; + }; + return { mx, push, calls, pushers, unsub, swapSub }; } describe('web push pusher', () => { @@ -47,7 +68,10 @@ describe('web push pusher', () => { expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); }); it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { - const p = webPushPusher(cfg, subscription(), 'phone') as unknown as Record & { + const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record< + string, + unknown + > & { data: Record; }; expect(p).toMatchObject({ @@ -91,4 +115,29 @@ describe('web push pusher', () => { await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); expect(w.unsub).toEqual([]); }); + it('the toggle is on only with a subscription and the pusher of this account for it', async () => { + const w = world(); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true); + // another account on the same browser: the subscription exists, its pusher does not + const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) }; + expect(await pushIsOn(other, w.push, cfg)).toBe(false); + await disablePush(w.mx, w.push, cfg); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + }); + it('a subscription made under another VAPID key is replaced, not reused', async () => { + const w = world(); + const old = [] as string[]; + w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer)); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(old).toEqual(['x']); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); + }); + it('a failed pusher write after subscribing leaves the toggle off', async () => { + const w = world(); + const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) }; + await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no'); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + }); }); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts index 65c0aca4..d57c2821 100644 --- a/src/app/features/push/pusher.ts +++ b/src/app/features/push/pusher.ts @@ -4,6 +4,7 @@ import type { ActivePushConfig } from './config'; /** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ export interface PusherClient { setPusher(p: IPusherRequest): Promise; + getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>; } export interface PushManagerPort { getSubscription(): Promise; @@ -54,22 +55,42 @@ export function webPushPusher( } as IPusherRequest; } +const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => { + const have = sub.options?.applicationServerKey; + return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer); +}; + +/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */ export async function enablePush( mx: PusherClient, push: PushManagerPort, cfg: ActivePushConfig, deviceName: string ): Promise { - const sub = - (await push.getSubscription()) ?? - (await push.subscribe({ - userVisibleOnly: true, - applicationServerKey: vapidKeyBytes(cfg.vapidPublicKey), - })); + const key = vapidKeyBytes(cfg.vapidPublicKey); + let sub = await push.getSubscription(); + if (sub && !sameKey(sub, key)) { + await sub.unsubscribe(); + sub = null; + } + sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key })); await mx.setPusher(webPushPusher(cfg, sub, deviceName)); } -/** Delete the pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ +/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */ +export async function pushIsOn( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig +): Promise { + const sub = await push.getSubscription(); + if (!sub) return false; + const pushkey = b64url(sub.getKey('p256dh')); + const { pushers } = await mx.getPushers(); + return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey); +} + +/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ export async function disablePush( mx: PusherClient, push: PushManagerPort, diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts index 81668a5f..86cf9147 100644 --- a/src/app/features/push/sessionStore.test.ts +++ b/src/app/features/push/sessionStore.test.ts @@ -29,6 +29,21 @@ describe('sessionStore', () => { await s.save('https://hs/', 5); expect(await s.load()).toBeUndefined(); }); + it('keeps the token only while a push subscription exists', async () => { + const port = memory(); + const s = sessionStore(port); + await s.saveIfSubscribed('https://hs/', 'tok', async () => false); + expect(port.held).toBeUndefined(); + await s.saveIfSubscribed('https://hs/', 'tok', async () => true); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + await s.saveIfSubscribed('https://hs/', 'tok', async () => false); + expect(port.held).toBeUndefined(); + await s.saveIfSubscribed('https://hs/', 'tok', async () => true); + await s.saveIfSubscribed('https://hs/', 'tok', () => + Promise.reject(new Error('no push manager')) + ); + expect(port.held).toBeUndefined(); + }); it('ignores a record that is not a session', async () => { const port = memory(); port.held = { baseUrl: 1 }; diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts index adaa711b..f1835e9c 100644 --- a/src/app/features/push/sessionStore.ts +++ b/src/app/features/push/sessionStore.ts @@ -1,7 +1,8 @@ // The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. // Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other -// origins; the token is already in the page's own storage). Written when the page hands the worker its session, -// deleted when the page hands it none (logout). Nothing else is stored, and nothing else reads it but the push handler. +// origins; the token is already in the page's own storage). Written when the page hands the worker its session +// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is +// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler. export interface SessionRecord { baseUrl: string; accessToken: string; @@ -68,6 +69,15 @@ export function sessionStore(port: RecordPort) { : undefined ); }, + /** The token is kept only while this browser holds a push subscription; without one the record is deleted. */ + async saveIfSubscribed( + baseUrl: unknown, + accessToken: unknown, + subscribed: () => Promise + ): Promise { + const keep = await subscribed().catch(() => false); + await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined); + }, async load(): Promise { const v = (await port.read()) as Partial | undefined; return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts index f1f766ce..5eaad32f 100644 --- a/src/app/features/push/worker.test.ts +++ b/src/app/features/push/worker.test.ts @@ -59,6 +59,11 @@ describe('push against a mock gateway and homeserver', () => { expect(shown[0].options.tag).toBeUndefined(); expect(shown[0].options.data).toBeUndefined(); }); + it('shows even when the window list cannot be read', async () => { + const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown).toHaveLength(1); + }); it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { const win = (visibilityState: string, focused: boolean) => ({ visibilityState, diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts index 5e3ccd75..a98f873d 100644 --- a/src/app/features/push/worker.ts +++ b/src/app/features/push/worker.ts @@ -27,7 +27,8 @@ export interface PushTarget { /** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ export async function handlePush(raw: unknown, w: WorkerPort): Promise { const ids = parsePush(raw); - if ((await w.windows()).some((c) => c.visibilityState === 'visible' && c.focused)) return; + const open = await w.windows().catch(() => []); + if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return; const shown = await describePush(ids, await w.loadSession(), w.fetch); await w.show(shown.title, { body: shown.body, diff --git a/src/sw.ts b/src/sw.ts index 4df85a88..ce9878c9 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -36,7 +36,13 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { - persisted.save(baseUrl, accessToken).catch(() => undefined); + persisted + .saveIfSubscribed( + baseUrl, + accessToken, + async () => !!(await self.registration.pushManager.getSubscription()) + ) + .catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { -- 2.47.3