pwa: installable manifest and Web Push stages 0-1 (dark by default) #121

Merged
void merged 2 commits from void/pwa-push-stage-0-1 into dev 2026-11-12 20:05:42 +00:00
21 changed files with 1105 additions and 1 deletions

View file

@ -28,6 +28,11 @@
"fileZoneShare": {
"enabled": false
},
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": {
"enabled": false,
"url": ""

View file

@ -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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -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<string>();
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 (
<SettingTile
title="Background Notifications"
description={
error || permission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{error ?? 'Notification permission is blocked. Allow it from the browser address bar.'}
</Text>
) : (
<span>
Notify when the app is closed. Installing the app to the home screen helps on iOS.
</span>
)
}
after={<Switch disabled={busy || permission === 'denied'} value={on} onChange={toggle} />}
/>
);
}

View file

@ -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();
});
});

View file

@ -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<PushConfig>;
export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined =>
c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined;

View file

@ -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)
);
});
});

View file

@ -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);
});
});

View file

@ -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<string, unknown>;
const n = (top.notification ?? top) as Record<string, unknown>;
const roomId = str(n.room_id);
if (!roomId) return undefined;
const content = (n.content ?? {}) as Record<string, unknown>;
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<Response>;
async function getJson(
f: Fetch,
session: SessionRecord,
path: string,
timeoutMs: number
): Promise<Record<string, unknown> | 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<string, unknown>) : 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<Shown> {
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<string, unknown>;
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) };
}

View file

@ -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<string, unknown>;
};
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);
});
});

View file

@ -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<unknown>;
getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>;
}
export interface PushManagerPort {
getSubscription(): Promise<PushSubscription | null>;
subscribe(o: {
userVisibleOnly: boolean;
applicationServerKey: Uint8Array;
}): Promise<PushSubscription>;
}
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<void> {
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<boolean> {
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<void> {
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();
}

View file

@ -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<string, Map<string, Map<string, unknown>>>();
const request = <T>(run: () => T) => {
const r = {} as IDBRequest<T>;
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<string, unknown>;
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<SessionRecord>({ baseUrl: 'https://hs/', accessToken: 'tok' });
await s.save(undefined, undefined);
expect(await s.load()).toBeUndefined();
});
});

View file

@ -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<unknown>;
write(value: SessionRecord | undefined): Promise<void>;
}
const DB = 'ratatoskr-sw';
const STORE = 'session';
const KEY = 'session';
const done = <T>(req: IDBRequest<T>): Promise<T> =>
new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
export function idbPort(factory: IDBFactory): RecordPort {
const open = (): Promise<IDBDatabase> => {
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<void>((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<void> {
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<boolean>
): Promise<void> {
const keep = await subscribed().catch(() => false);
await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined);
},
async load(): Promise<SessionRecord | undefined> {
const v = (await port.read()) as Partial<SessionRecord> | undefined;
return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string'
? { baseUrl: v.baseUrl, accessToken: v.accessToken }
: undefined;
},
};
}

View file

@ -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<string, unknown>;
roomNames?: Record<string, string>;
failAll?: boolean;
}) {
const seen: { url: string; auth: string | null }[] = [];
const fetchFn = async (url: string, init: RequestInit): Promise<Response> => {
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: {} }],
},
});

View file

@ -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]);
}

View file

@ -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<WorkerPort> = {}) {
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/']);
});
});

View file

@ -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<unknown>;
postMessage(m: unknown): void;
}[]
>;
openWindow(url: string): Promise<unknown>;
show(title: string, options: NotificationOptions): Promise<void>;
loadSession(): Promise<SessionRecord | undefined>;
fetch(url: string, init: RequestInit): Promise<Response>;
/** 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<void> {
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<void> {
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);
}

View file

@ -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() {
}
/>
</SequenceCard>
{pushCfg && pushSupported() && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<PushNotification cfg={pushCfg} />
</SequenceCard>
)}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"

View file

@ -1,6 +1,7 @@
import { createContext, useContext } from 'react';
import type { DingsConfig } from '../features/dings/dings';
import type { BackgroundSceneConfig } from '../features/scene/tier';
import type { PushConfig } from '../features/push/config';
export type HashRouterConfig = {
enabled?: boolean;
@ -41,6 +42,9 @@ export type ClientConfig = {
// service's own registration page (a value, never a route this client invents).
registration?: RegistrationConfig;
// Web Push to the installed app (src/app/features/push). Off unless all three keys are filled; ships empty.
push?: PushConfig;
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
previewDemo?: boolean;
};

View file

@ -29,6 +29,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { mDirectAtom } from '../../state/mDirectList';
import { motifFor } from '../../features/dings/dings';
import { useDings } from '../../features/dings/useDings';
import { usePushOpen } from '../../features/push/usePushOpen';
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@ -280,6 +281,7 @@ type ClientNonUIFeaturesProps = {
};
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
usePushOpen();
return (
<>
<SystemEmojiFeature />

View file

@ -1,8 +1,14 @@
/// <reference lib="WebWorker" />
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));
});