From d520af51b2d3b34ebd0129199de143d6971d8660 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:49:41 +0100 Subject: [PATCH] fix: restore push when the browser replaces its subscription with no tab open --- .../push-relay-registrations.test.ts | 40 ++++++++++++ .../sw-push-subscription-change.test.ts | 49 +++++++++++++-- lib/pwa-notify/worker-state.ts | 54 ++++++++++++++++ lib/web-push.ts | 7 +++ public/sw.js | 61 ++++++++++++++++--- 5 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 lib/__tests__/push-relay-registrations.test.ts diff --git a/lib/__tests__/push-relay-registrations.test.ts b/lib/__tests__/push-relay-registrations.test.ts new file mode 100644 index 000000000..de53872fc --- /dev/null +++ b/lib/__tests__/push-relay-registrations.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state'; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined), + put: async (key: string, res: Response) => { store.set(key, await res.text()); }, + }), + }); +}); + +const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null'); + +describe('what the worker keeps to re-register without a tab', () => { + it('keeps one entry per account and the key', async () => { + await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY'); + await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY'); + expect(stored()).toEqual({ + applicationServerKey: 'KEY', + entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } }, + }); + }); + + it('forgets an account, and leaves the others', async () => { + await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY'); + await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY'); + await forgetRelayRegistration('a'); + expect(Object.keys(stored().entries)).toEqual(['b']); + }); + + it('stays out of the way without a Cache API', async () => { + vi.stubGlobal('caches', undefined); + await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined(); + await expect(forgetRelayRegistration('a')).resolves.toBeUndefined(); + }); +}); diff --git a/lib/__tests__/sw-push-subscription-change.test.ts b/lib/__tests__/sw-push-subscription-change.test.ts index b379d6c85..813a8fa69 100644 --- a/lib/__tests__/sw-push-subscription-change.test.ts +++ b/lib/__tests__/sw-push-subscription-change.test.ts @@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest'; // in a VM with a minimal ServiceWorkerGlobalScope and fire the event. const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); -function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { +function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown) { const listeners = new Map void>(); - const subscribe = vi.fn(async () => ({})); + const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) }; + const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription); + const relayCalls: Array<{ url: string; body: unknown }> = []; const self = { location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), @@ -18,7 +20,14 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { registration: { pushManager: { subscribe } }, }; const context = vm.createContext({ - self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console, + self, + atob, + caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) }, + fetch: vi.fn(async (url: string, init: { body: string }) => { + relayCalls.push({ url, body: JSON.parse(init.body) }); + return { ok: true }; + }), + URL, URLSearchParams, Response, Date, console, Uint8Array, }); vm.runInContext(SW_SOURCE, context); @@ -27,13 +36,45 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) }); await Promise.all(waits); } - return { fire, subscribe }; + return { fire, subscribe, relayCalls, newSubscription }; } // The push service dropped or replaced the subscription while no page was // involved (#1321). The worker puts a subscription back and tells the open // tabs, which hold the login needed to register the new endpoint. describe('service worker pushsubscriptionchange', () => { + const REGISTRATIONS = { + applicationServerKey: 'QUJD', + entries: { + a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' }, + b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' }, + }, + }; + + it('tells the relay the new endpoint itself, for every account, with no tab open', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([ + { url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } }, + { url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } }, + ]); + }); + + it('subscribes with the kept key when the browser gives no old subscription', async () => { + const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null }); + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true }); + expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]); + expect(relayCalls).toHaveLength(2); + }); + + it('calls no relay for a browser where push was never on', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([]); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + it('re-subscribes with the old key and tells the open tabs to re-register', async () => { const tab = { postMessage: vi.fn() }; const { fire, subscribe } = loadWorker([tab]); diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts index 161f3885c..283e7b6dd 100644 --- a/lib/pwa-notify/worker-state.ts +++ b/lib/pwa-notify/worker-state.ts @@ -29,6 +29,60 @@ export function pendingReplyKey(origin: string): string { return `${origin}${BASE_PATH}/__push-state/pending-reply`; } +/** The cache key sw.js finds the relay registrations under. */ +export function relayRegistrationsKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/relay-registrations`; +} + +export interface RelayRegistration { + relay: string; + subscriptionId: string; + accountLabel?: string; +} + +interface RelayRegistrations { + applicationServerKey?: string; + entries: Record; +} + +async function readRelayRegistrations(cache: Cache, key: string): Promise { + const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined; + return stored?.entries ? stored : { entries: {} }; +} + +/** + * Lets the worker re-register a new browser endpoint with the relay when no tab + * is open (pushsubscriptionchange). None of this is secret to the browser that + * subscribed, and it opens nothing but the relay record. + */ +export async function keepRelayRegistration(accountId: string, entry: RelayRegistration, applicationServerKey: string): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = relayRegistrationsKey(window.location.origin); + const stored = await readRelayRegistrations(cache, key); + stored.entries[accountId] = entry; + stored.applicationServerKey = applicationServerKey; + await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // A new endpoint then waits for the page's re-sync. + } +} + +export async function forgetRelayRegistration(accountId: string): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = relayRegistrationsKey(window.location.origin); + const stored = await readRelayRegistrations(cache, key); + if (!(accountId in stored.entries)) return; + delete stored.entries[accountId]; + await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // Signing out deletes the store anyway. + } +} + export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise { try { if (typeof caches === 'undefined') return; diff --git a/lib/web-push.ts b/lib/web-push.ts index 3fcd93ccb..d3bf880d3 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -13,6 +13,7 @@ import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts'; import { noThirdPartyTraffic } from '@/lib/third-party-mode'; import { IS_LITE } from '@/lib/lite'; import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update'; +import { forgetRelayRegistration, keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, @@ -801,6 +802,11 @@ async function enableWebPushForThisTab( }, accountLabel: params.accountLabel, }); + await keepRelayRegistration(accountId, { + relay: relayBaseUrl, + subscriptionId: deviceClientId, + ...(params.accountLabel ? { accountLabel: params.accountLabel } : {}), + }, vapidPublicKey); // Reuse the JMAP-side PushSubscription if the server still has it, just // refreshing the expiry so it doesn't time out between sessions. With @@ -983,6 +989,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise character.charCodeAt(0)); +} + // The push service dropped or replaced the browser's subscription (expiry, // a key rotation, revoked permission). Re-subscribe with the same relay key -// unless the browser already did, then tell the open tabs: only a page has -// the login needed to register the new endpoint with the relay and the mail -// server (resyncWebPush in lib/web-push.ts). With no tab open, the next page -// load's re-sync picks the new subscription up. +// unless the browser already did. The mail server's subscription points at the +// relay record, not at the endpoint, so telling the relay is all it takes, and +// that needs no login: push is back with no tab open. Open tabs hear of it too, +// for what only a page can do (resyncWebPush in lib/web-push.ts). async function handlePushSubscriptionChange(event) { const old = event.oldSubscription; - if (!event.newSubscription && old && old.options) { - try { - await self.registration.pushManager.subscribe(old.options); - } catch (_) { - // Permission revoked or push service unreachable: the page's re-sync - // subscribes again itself when it can. + const registrations = await readRelayRegistrations(); + let subscription = event.newSubscription || null; + if (!subscription) { + const options = old && old.options + ? old.options + : registrations && registrations.applicationServerKey + ? { userVisibleOnly: true, applicationServerKey: applicationServerKeyOf(registrations.applicationServerKey) } + : null; + if (options) { + try { + subscription = await self.registration.pushManager.subscribe(options); + } catch (_) { + // Permission revoked or push service unreachable: the page's re-sync + // subscribes again itself when it can. + } + } + } + if (subscription && registrations && typeof subscription.toJSON === "function") { + const json = subscription.toJSON(); + for (const entry of Object.values(registrations.entries)) { + try { + await fetch(`${entry.relay}/api/push/register/web`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subscriptionId: entry.subscriptionId, subscription: json, accountLabel: entry.accountLabel }), + }); + } catch (_) { + // The page's re-sync registers it when it next runs. + } } } const allClients = await self.clients.matchAll({