Files
crowd-funder-for-time-pwa/doc/CHANGES/PLAN-restore-web-push.md
T

7.6 KiB

Plan: Web Push on the Web Build

Goal

A user of the web build (browser tab or installed web app) can turn on the Daily Reminder and New Activity notifications and receive them, matching what native builds deliver.

Requirements:

  1. A DIRECT_NOTIFICATION push shows its message as sent.
  2. A DAILY_CHECK push shows the count of new activity for the active identity, using the same Endorser query the native fetcher uses.
  3. The service worker never holds a long-lived credential: it holds only day-scoped JWTs, the same pool native builds hand to their background fetcher.
  4. The Notifications section of the Account view works on the web build.

Can FCM replace Web Push?

No. Firebase Cloud Messaging on the web (the firebase/messaging SDK) is a layer over the browser Push API: getToken() requires a registered service worker (firebase-messaging-sw.js by default), and that worker must display the notification, the same as a plain Web Push worker. FCM changes only the sending side — the push server would call the FCM HTTP v1 API instead of sending VAPID-signed Web Push requests — and it adds a Google dependency and Firebase config to the web build. It removes none of the work below.

Current state

  • public/sw.js is registered by src/main.web.ts on the web platform. It handles only the Web Share Target POST; it has no push or notificationclick handler.
  • The Notifications section of the Account view renders only on native (v-if="isNativePlatform", src/views/AccountViewView.vue:81), and the "Notification Push Server" setting is hidden with v-if="false" (src/views/AccountViewView.vue:719). Web users cannot subscribe.
  • src/components/PushNotificationPermission.vue contains the web subscription flow:
    • GET {pushUrl}/web-push/vapid for the VAPID key (:242);
    • pushManager.subscribe({ userVisibleOnly: true, ... }) (:538);
    • POST of the subscription to the relative URL /web-push/subscribe (:583), so the web host must proxy that path to the push server;
    • askPermission() sends the worker the placeholder secret "temporary-secret" via SEND_LOCAL_DATA, which nothing reads.
  • The push server sends a JSON payload whose title is DAILY_CHECK (run the new-activity check) or DIRECT_NOTIFICATION (show message as-is).
  • Account data lives in SQLite through absurd-sql, which a service worker cannot open. The worker therefore cannot decrypt the identity and sign its own JWTs.
  • Native builds solve the same problem with mintBackgroundJwtTokenPool() (src/libs/crypto/backgroundJwtPool.ts): 100 JWTs, each valid for one UTC day, stored at index epochDay % 100, handed to the background fetcher by configureNativeFetcherIfReady() (src/services/notifications/nativeFetcherConfig.ts). Passkey (did:peer) identities cannot mint the pool and raise BackgroundJwtUnsupportedIdentityError.

Work

1. Service worker push handling (public/sw.js)

  • push: parse the JSON payload.
    • DIRECT_NOTIFICATION: showNotification("Direct Message", { body: message }).
    • DAILY_CHECK (and any other title): take the token for today's slot from the pool, GET {apiServer}/api/v2/report/claims with it, count claims newer than the stored lastNotifiedClaimId, store the newest ID, and show "There is 1 new activity" / "There are N new activities".
  • notificationclick: close the notification, focus an open app window or open /.
  • Worker state (token pool, apiServer, activeDid, lastNotifiedClaimId) lives in the worker's own IndexedDB database, never in the app's SQLite database.

2. Hand the token pool to the worker

  • On app start, identity change, and pool expiry, mint the pool with mintBackgroundJwtTokenPool() and postMessage it to the worker together with apiServer and activeDid. Reuse the trigger points in src/services/notifications/notificationAuthLifecycle.ts (onNotificationAuthMayBeReady).
  • Delete the SEND_LOCAL_DATA / placeholder-secret path in PushNotificationPermission.vue.
  • Passkey identities: do not offer New Activity notifications (the Daily Reminder needs no token).

3. Empty daily checks

Chrome requires every push received with userVisibleOnly: true to show a notification. A DAILY_CHECK that finds nothing new must show one anyway, or Chrome eventually shows its own "This site has been updated in the background" notice. Choose one (see Open questions):

  • show a quiet "No new activity" notification and close it after a short delay;
  • have the push server skip pushes when there is nothing new, which requires the server to query Endorser on the user's behalf.

4. Re-enable the UI on the web build

  • Render the Notifications section on web when "PushManager" in window and a service worker is registered, instead of only on native.
  • Restore the "Notification Push Server" setting for web.
  • iOS Safari delivers Web Push only to web apps added to the Home Screen (iOS 16.4+). public/manifest.webmanifest is linked from index.html and makes the app installable; the UI must explain the Home Screen requirement on iOS.

5. Hosting

  • Confirm each web host (test, production, Docker docker/*.conf) proxies /web-push/subscribe to the push server, or change the client to POST to {pushUrl}/web-push/subscribe.
  • Serve sw.js with Cache-Control: no-cache so worker updates reach users promptly.

6. Tests

  • Playwright: register the worker, then deliver pushes with the Chrome DevTools Protocol ServiceWorker.deliverPushMessage and assert on the shown notification (registration.getNotifications()), for both payload types.
  • Unit-test the claim-counting function, extracted so it runs outside the worker.

Estimate

Two to four days of work, plus push-server and nginx checks (low confidence: the push server's current behavior and routing are not visible from this repository).

Open questions

  • Empty daily checks: silent-notification workaround, or server-side filtering?
  • Does the push server serve /web-push/vapid and accept /web-push/subscribe, and does it send DAILY_CHECK daily?
  • Should New Activity on web use the same Endorser query as the native fetcher (starred-plan changes) rather than /api/v2/report/claims, so both platforms report the same thing?

Rejected

  • FCM for web: still needs a service worker; adds a Google dependency to the web build and a server-side switch to the FCM API. See "Can FCM replace Web Push?".
  • Reviving the old worker scripts (sw_scripts/safari-notifications.js): they read the Dexie TimeSafari IndexedDB database, which no longer holds accounts, and decrypted the identity inside the worker using a secret passed from the page, shipping NaCl and noble crypto libraries into the worker. The day-scoped token pool needs neither.

History

  • 2025-02-18 (2746b8fe): the Vite config reorganization switched the web build to a Workbox-generated worker, so the custom push, notification-click, and share-target handlers stopped shipping.
  • 2025-07-18 (8bc1c521): VitePWA and the generated worker were removed; the web manifest stopped being generated and linked.
  • The leftover sw_scripts/ files and PWA config were deleted, and public/sw.js was added for the Web Share Target only.

Checklist

  • Decide the empty-daily-check behavior
  • Confirm push-server endpoints and routing per environment
  • push and notificationclick handlers in public/sw.js
  • Token-pool handoff to the worker; remove the placeholder secret
  • Re-enable the web Notifications UI, with the iOS Home Screen note
  • Playwright push-delivery tests