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
Member

What changed

Stages 0 and 1 of /opt/commons/handoff/ratatoskr-app-push-gap-20261110.md. Nothing is deployed and the homeserver config is untouched.

Stage 0

  • public/manifest.json: id: "/", scope: "./", a 512 maskable icon (public/res/android/android-chrome-maskable-512x512.png, the mark inside the 80% safe zone on a full-bleed gradient of the icon's own colours), and the orientation: portrait lock dropped (it locked tablets and desktop windows; nothing in the app needs it). id: "/" is what Chrome already derives from start_url: "./" at a site root, so the installed app keeps its identity. At a sub-path deployment (CINNY_BASE) it would not; chat.gamecult.org is at the root.
  • The service worker persists the session so a push can wake it with no tab.

What is stored. One IndexedDB record, ratatoskr-sw / session / key session: { baseUrl, accessToken }. Nothing else. It is written only while the browser holds a push subscription (the worker checks pushManager.getSubscription() on every session hand-off), and deleted when the page hands the worker no session (logout) or the subscription is gone. A browser that never turned push on stores nothing. Only the push handler reads it, and sends the token only to that baseUrl (two reads: the event and the room name).

Stage 1 (src/app/features/push/, wired in src/sw.ts)

  • push handler: always shows a notification (userVisibleOnly), except when a window is visible and focused. Ids-only pushes are resolved through the homeserver with the stored token (clear-text rooms; an encrypted event or any failure shows "New message"). One notification per room (tag, renotify).
  • notificationclick: focuses an open window and posts the room to it; with none open, starts the app with ?pushRoom=&pushEvent=, which usePushOpen (mounted in ClientNonUIFeatures) turns into navigateRoom and strips from the URL.
  • Settings toggle "Background Notifications" (PushNotification.tsx): shown only when config.json push has vapidPublicKey, gatewayUrl and appId all filled and the browser supports it. On means a subscription AND this account's pusher for it. Enable subscribes then sets the pusher (a subscription under another VAPID key is replaced); disable deletes the pusher, then unsubscribes.
  • config.json ships push: { vapidPublicKey: "", gatewayUrl: "", appId: "" }: off. The repo config.json already differs from the live one (live is terrain without the newer blocks), so build-prod-dist.sh needs ALLOW_CONFIG_DRIFT=1 until the live file is reconciled; this adds one more block to that diff.
  • Pusher shape is Sygnal's webpush type: pushkey = the subscription's p256dh, data.endpoint/auth read by Sygnal (I read sygnal/webpushpushkin.py), data.url/format read by Synapse, format: event_id_only.

Verification

  • vitest run on a fresh npm ci: 94 files, 1083 tests; 1082 pass. The one failure is src/app/features/scene/index.test.ts timing out in the full run; it passes alone (14/14), on this branch and on base. 34 new tests in src/app/features/push/: payload parsing and description against a mock homeserver (token, URLs, 401, network down, encrypted, clipping), the push and click handlers against a mock worker port fed the Matrix gateway body, the pusher against a mock homeserver (shape, order, failure, toggle truth, stale key), the session store through a fake IndexedDB, the manifest and its icon files, and the shipped-empty config.
  • eslint clean on touched files, prettier clean, vite build passes. tsc --noEmit is noisy at base (matrix-js-sdk types); nothing new beyond the same IPusherRequest import error class.
  • Real Chrome 156 on Starfire (GTX 1070), the built dist/sw.js on a localhost origin: registered; a session hand-off with no subscription stores nothing, and a logout leaves nothing; with a stored session a CDP-delivered push event called showNotification('Ratatoskr', {body: 'New message', tag: '!r:x', renotify, data: {roomId, eventId}}) with the homeserver unreachable. Not run on a phone, and not against Sygnal.
  • One Soul pass (separate context) before this PR. Findings and what I did: toggle state read the subscription, not the account's pusher; a stale VAPID key was reused; the token was stored on every deployment, push or not; a failing client list skipped the notification. All four fixed in the second commit, with tests; I did not send the fixes back to Soul. Accepted as they are: the worker's record is global and last-writer-wins, and a second tab racing a logout can leave a dead token in the record until the next hand-off; logout does not unsubscribe the browser (Synapse drops the pushers tied to the logged-out device, not checked here).

Not done (Stages 2 and 3, follow-ups filed)

Sygnal, VAPID keys, nginx location, push.include_content and the live config.json push block are Eureka's host work; the phone test needs the operator's phone.

🤖 Generated with Claude Code

## What changed Stages 0 and 1 of `/opt/commons/handoff/ratatoskr-app-push-gap-20261110.md`. Nothing is deployed and the homeserver config is untouched. **Stage 0** - `public/manifest.json`: `id: "/"`, `scope: "./"`, a 512 maskable icon (`public/res/android/android-chrome-maskable-512x512.png`, the mark inside the 80% safe zone on a full-bleed gradient of the icon's own colours), and the `orientation: portrait` lock dropped (it locked tablets and desktop windows; nothing in the app needs it). `id: "/"` is what Chrome already derives from `start_url: "./"` at a site root, so the installed app keeps its identity. At a sub-path deployment (`CINNY_BASE`) it would not; chat.gamecult.org is at the root. - The service worker persists the session so a push can wake it with no tab. **What is stored.** One IndexedDB record, `ratatoskr-sw` / `session` / key `session`: `{ baseUrl, accessToken }`. Nothing else. It is written only while the browser holds a push subscription (the worker checks `pushManager.getSubscription()` on every session hand-off), and deleted when the page hands the worker no session (logout) or the subscription is gone. A browser that never turned push on stores nothing. Only the push handler reads it, and sends the token only to that `baseUrl` (two reads: the event and the room name). **Stage 1** (`src/app/features/push/`, wired in `src/sw.ts`) - `push` handler: always shows a notification (`userVisibleOnly`), except when a window is visible and focused. Ids-only pushes are resolved through the homeserver with the stored token (clear-text rooms; an encrypted event or any failure shows "New message"). One notification per room (`tag`, `renotify`). - `notificationclick`: focuses an open window and posts the room to it; with none open, starts the app with `?pushRoom=&pushEvent=`, which `usePushOpen` (mounted in `ClientNonUIFeatures`) turns into `navigateRoom` and strips from the URL. - Settings toggle "Background Notifications" (`PushNotification.tsx`): shown only when `config.json` `push` has `vapidPublicKey`, `gatewayUrl` and `appId` all filled and the browser supports it. On means a subscription AND this account's pusher for it. Enable subscribes then sets the pusher (a subscription under another VAPID key is replaced); disable deletes the pusher, then unsubscribes. - `config.json` ships `push: { vapidPublicKey: "", gatewayUrl: "", appId: "" }`: off. The repo `config.json` already differs from the live one (live is `terrain` without the newer blocks), so `build-prod-dist.sh` needs `ALLOW_CONFIG_DRIFT=1` until the live file is reconciled; this adds one more block to that diff. - Pusher shape is Sygnal's `webpush` type: `pushkey` = the subscription's p256dh, `data.endpoint/auth` read by Sygnal (I read `sygnal/webpushpushkin.py`), `data.url/format` read by Synapse, `format: event_id_only`. ## Verification - `vitest run` on a fresh `npm ci`: 94 files, 1083 tests; 1082 pass. The one failure is `src/app/features/scene/index.test.ts` timing out in the full run; it passes alone (14/14), on this branch and on base. 34 new tests in `src/app/features/push/`: payload parsing and description against a mock homeserver (token, URLs, 401, network down, encrypted, clipping), the push and click handlers against a mock worker port fed the Matrix gateway body, the pusher against a mock homeserver (shape, order, failure, toggle truth, stale key), the session store through a fake IndexedDB, the manifest and its icon files, and the shipped-empty config. - `eslint` clean on touched files, `prettier` clean, `vite build` passes. `tsc --noEmit` is noisy at base (matrix-js-sdk types); nothing new beyond the same `IPusherRequest` import error class. - Real Chrome 156 on Starfire (GTX 1070), the built `dist/sw.js` on a localhost origin: registered; a session hand-off with no subscription stores nothing, and a logout leaves nothing; with a stored session a CDP-delivered push event called `showNotification('Ratatoskr', {body: 'New message', tag: '!r:x', renotify, data: {roomId, eventId}})` with the homeserver unreachable. Not run on a phone, and not against Sygnal. - One Soul pass (separate context) before this PR. Findings and what I did: toggle state read the subscription, not the account's pusher; a stale VAPID key was reused; the token was stored on every deployment, push or not; a failing client list skipped the notification. All four fixed in the second commit, with tests; I did not send the fixes back to Soul. Accepted as they are: the worker's record is global and last-writer-wins, and a second tab racing a logout can leave a dead token in the record until the next hand-off; logout does not unsubscribe the browser (Synapse drops the pushers tied to the logged-out device, not checked here). ## Not done (Stages 2 and 3, follow-ups filed) Sygnal, VAPID keys, nginx location, `push.include_content` and the live `config.json` push block are Eureka's host work; the phone test needs the operator's phone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 <noreply@anthropic.com>
push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
695a016d4f
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 <noreply@anthropic.com>
void merged commit a8d39ee917 into dev 2026-11-12 20:05:42 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Ratatoskr!121
No description provided.