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 00000000..211a0650 Binary files /dev/null and b/public/res/android/android-chrome-maskable-512x512.png differ diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx new file mode 100644 index 00000000..a818354e --- /dev/null +++ b/src/app/features/push/PushNotification.tsx @@ -0,0 +1,72 @@ +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 { pushSessionToSW } from '../../../sw-session'; +import { disablePush, enablePush, pushIsOn } 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) => pushIsOn(mx, m, cfg)) + .then(setOn) + .catch(() => undefined); + }, [cfg, mx]); + + 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); + } + // 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.'); + } 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..ffefa1f4 --- /dev/null +++ b/src/app/features/push/pusher.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import type { IPusherRequest } from 'matrix-js-sdk'; +import { + disablePush, + enablePush, + PushManagerPort, + pushIsOn, + 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[], 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 () => { + 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); + }, + // 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, + subscribe: async (o) => { + calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`); + sub = subscription(unsub); + return sub; + }, + }; + const swapSub = (s: PushSubscription | null) => { + sub = s; + }; + return { mx, push, calls, pushers, unsub, swapSub }; +} + +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< + string, + unknown + > & { + 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([]); + }); + 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 new file mode 100644 index 00000000..d57c2821 --- /dev/null +++ b/src/app/features/push/pusher.ts @@ -0,0 +1,107 @@ +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; + getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>; +} +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; +} + +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 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)); +} + +/** 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, + 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..86cf9147 --- /dev/null +++ b/src/app/features/push/sessionStore.test.ts @@ -0,0 +1,111 @@ +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('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 }; + 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..f1835e9c --- /dev/null +++ b/src/app/features/push/sessionStore.ts @@ -0,0 +1,88 @@ +// 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 +// 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; +} + +/** 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 + ); + }, + /** 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' + ? { 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..5eaad32f --- /dev/null +++ b/src/app/features/push/worker.test.ts @@ -0,0 +1,108 @@ +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 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, + 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..a98f873d --- /dev/null +++ b/src/app/features/push/worker.ts @@ -0,0 +1,62 @@ +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); + 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, + // 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..ce9878c9 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,13 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { + 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 { @@ -156,3 +169,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)); +});