fix(puter-js): page loads check push status instead of registering again (#4480)

* fix(puter-js): background push syncs never prompt for sign-in

The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.

* fix(puter-js): turning push off in one tab stops other tabs' syncs

Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.

* fix(puter-js): wait for a push confirmation only once the lock is held

A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.

* test(puter-js): reach the push sync in the boot sign-in e2e

The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.

* fix(puter-js): sign-out and disable() remove only Puter's push subscription

Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.

* fix(puter-js): sign-out and a user change end this browser's pushes

Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.

A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.

A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.

* fix(puter-js): record a push opt-in before other tabs can check it

enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.

* fix(puter-js): push cleanup runs without the lock where Web Locks refuse

In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.

* fix(puter-js): send the sign-out removal again when keepalive is refused

A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.

* test(puter-js): run the push boot test on full Chromium and skip it without push support

The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.

* fix(puter-js): a user change finishes its push cleanup before enable()

enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.

* fix(puter-js): a tap without a URL opens the site the push came from

A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.

* fix(puter-js): push taps go to top-level windows only

push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.

* fix(puter-js): deliver a push tap to the page once

push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.

* fix(puter-js): clear stored push taps that nobody reads

push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.

* fix(puter-js): a late page already at the tap's URL still gets the tap

When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.

* fix(puter-js): a push tap runs at most once when IndexedDB fails

A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.

* fix(puter-js): take a tap back and keep it for the next page in one step

When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.

* test(puter-js): shorten the fake IndexedDB comment

* test(puter-js): keep the push boot test comment to one line

* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again

The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.

* fix(puter-js): a page load checks push status instead of registering again

* fix(puter-js): subscriptions made with an earlier Puter key are replaced

* fix(puter-js): a replaced browser subscription is picked up on the next visit

* fix(puter-js): a page load that only checks push status still looks for a newer worker

* fix(puter-js): the worker subscribes again only where a Puter page used it

* docs(puter-js): soften the note on subscriptions Puter made earlier

* fix(puter-js): a status answer on load leaves a subscription another tab just turned on
This commit is contained in:
Juan Fernando Castro authored and GitHub committed 2026-11-04 10:15:25 -05:00
1 parent a9a8df6409
commit 848e336455
9 files changed
+768 -43

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ It rejects with an error carrying `message` and `code`:
| `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click, then call [`enable()`](/Notifications/enable/) again. |
| `push_worker_unavailable` | The service worker couldn't be registered or didn't start, for example because nothing is served at its path. |
| `push_worker_conflict` | Another service worker controls the scope. Add the `importScripts()` line to that worker and pass its path as `serviceWorker`. |
| `push_subscription_conflict` | The worker already has a push subscription from another service, such as Firebase or OneSignal. Give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. |
| `push_subscription_conflict` | The worker already has a push subscription from another service, such as Firebase or OneSignal. Give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. A subscription Puter made earlier, even with a key Puter has since changed, is replaced instead. |
| `push_subscribe_failed` | The browser couldn't create a push subscription, usually because it couldn't reach its push service. |
| `push_subscription_invalid` | The browser's push service refused the subscription, even after a fresh one. |
| `push_endpoint_revoked` | Another Puter account turned your app off on this browser's subscription, and a fresh subscription couldn't replace it. |
+2 -1
View File
@@ -56,7 +56,7 @@ importScripts('https://js.puter.com/push-sw.js');
The worker follows the browser's service worker rules:
- **One worker per scope.** If your site already has a service worker for that scope, add the `importScripts()` line to it and pass its path as `serviceWorker` to [`onPush()`](/Notifications/onPush/), [`enable()`](/Notifications/enable/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Otherwise [`enable()`](/Notifications/enable/) rejects with `push_worker_conflict` and leaves your worker in place. Puter's script only adds event listeners and only handles pushes sent by Puter, so your worker's own install, fetch and push handling keep working.
- **One push subscription per worker.** If that worker already gets pushes from another service, such as Firebase or OneSignal, give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. Otherwise [`enable()`](/Notifications/enable/) rejects with `push_subscription_conflict`.
- **One push subscription per worker.** If that worker already gets pushes from another service, such as Firebase or OneSignal, give Puter its own worker in a folder, such as `/puter/puter-sw.js`, and pass that path as `serviceWorker`. Otherwise [`enable()`](/Notifications/enable/) rejects with `push_subscription_conflict`. A subscription Puter made earlier, even with a key Puter has since changed, is replaced instead.
- **Content-Security-Policy.** If the worker is served with a Content-Security-Policy, its `script-src` has to allow `https://js.puter.com` and `'unsafe-eval'`. The worker re-creates your handler from its source with `new Function`.
- **Updates.** A changed worker only runs once it activates. Call `self.skipWaiting()` in it, or close every tab of your site.
- **Errors.** An error your handler throws in the worker shows only in the service worker's console, not the page's. In Chrome, open it from DevTools under **Application** > **Service workers**.
@@ -302,6 +302,7 @@ The browser can be allowed to show notifications while the operating system hide
| It rejects with `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click first. |
| The `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout` | The worker doesn't import `push-sw.js`, or the confirmation push didn't arrive within 30 seconds. The next page load tries again. |
| A push shows the plain title and text, not your handler's result | This browser hasn't run [`onPush()`](/Notifications/onPush/) for that `channel` yet, or the handler threw, took over 5 seconds, or used an outside variable. Check the worker's console. |
| Pushes stop until the user opens your site again | The browser replaced its push subscription for your site, as browsers sometimes do. Your service worker keeps the new one, and the next page load that calls [`onPush()`](/Notifications/onPush/) registers it with Puter. Pushes sent before that don't arrive. |
| No push while your site is open in a tab | The open page received the notification live. It shows one only when it's hidden or its window isn't focused. |
| No push at all | The browser isn't running, the operating system hides its notifications ([above](#operating-system-settings)), the user reached the [push limit](/rate-limits-and-quotas/#notifications) for your app, or the user has no usage credit left. |
+108 -2
View File
@@ -9,6 +9,8 @@ import { request } from './lib/api.js';
import {
CLICK_KEEP_MS,
HANDLERS_STORE,
SUBSCRIPTION_CHANGED_PREFIX,
SYNCED_PREFIX,
TAP_BASE_PREFIX,
forgetOldClicks,
forgetPushState,
@@ -26,6 +28,8 @@ import {
removeWebPush,
syncWebPush,
unsubscribeWebPush,
usesPuterKey,
webPushCheck,
webPushState,
webPushSupported,
workerFor,
@@ -63,6 +67,8 @@ const HANDLER_TIMEOUT_MS = 5_000;
const WORKER_SHOW_TIMEOUT_MS = 2_000;
/** How long after load a page a tap opened waits for a `click` handler. */
const UNREAD_TAP_MS = 10_000;
/** How long a load only asks the server about a subscription it confirmed. */
const SYNCED_FOR_MS = 24 * 60 * 60 * 1000;
/** Sync failures that are the server's or the user's choice, not a fault. */
const QUIET_SYNC_ERRORS = new Set([
'push_device_removed',
@@ -408,7 +414,7 @@ export class NotificationsModule extends EventListener {
(event) => {
if (event.data?.type !== SUBSCRIPTION_CHANGED_MESSAGE) return;
if (this.#pushHandlers.size > 0 && this.#worker)
void this.#syncIfOptedIn(this.#worker);
void this.#syncIfOptedIn(this.#worker, { full: true });
},
);
}
@@ -440,6 +446,7 @@ export class NotificationsModule extends EventListener {
interactive: true,
withLock: withPushLock,
onAnnounced: () => this.#recordOptIn(true),
onSynced: (endpoint) => this.#noteSynced(worker, endpoint),
});
try {
// The GUI reads the registered device right after.
@@ -715,22 +722,121 @@ export class NotificationsModule extends EventListener {
};
}
async #syncIfOptedIn(worker) {
/** With `full`, registers again even when a status call would do. */
async #syncIfOptedIn(worker, { full = false } = {}) {
const optedIn = await this.#readOptIn();
if (optedIn === false) await this.#endUnwanted();
if (!optedIn) return;
if (!full && (await this.#settledByStatus(worker))) return;
await this.#finishSync(
syncWebPush(this.puter, {
worker,
signal: this.#stopVerifying.signal,
stillWanted: async () => (await this.#readOptIn()) === true,
withLock: withPushLock,
onSynced: (endpoint) => this.#noteSynced(worker, endpoint),
}),
worker,
'could not keep the push subscription current',
);
}
/** Settles the load with a status call when that's enough; false if not. */
async #settledByStatus(worker) {
const signal = this.#stopVerifying.signal;
let subscription = null;
let uuid = null;
let synced = null;
try {
uuid = await this.#userUuid();
if (!uuid) return false;
let changed;
let current;
[synced, changed, current] = await Promise.all([
idbGet(HANDLERS_STORE, SYNCED_PREFIX + uuid),
idbGet(
HANDLERS_STORE,
SUBSCRIPTION_CHANGED_PREFIX + workerScope(worker),
),
currentWebPush({ worker }),
]);
const age = Date.now() - synced?.at;
if (
changed != null ||
!current ||
synced?.endpoint !== current.endpoint ||
!(age >= 0 && age < SYNCED_FOR_MS) ||
!(await usesPuterKey(current))
)
return false;
subscription = current;
} catch {
return false;
}
this.#checkWorkerUpdate(worker);
const answer = await webPushCheck(this.puter, subscription.endpoint);
if (signal.aborted) return true;
if (answer?.state === 'removed' || answer?.state === 'revoked') {
await withPushLock(async () => {
// Another tab's enable() since the check wins.
const [now, record] = await Promise.all([
currentWebPush({ worker }),
idbGet(HANDLERS_STORE, SYNCED_PREFIX + uuid),
]).catch(() => []);
if (
!now ||
now.endpoint !== subscription.endpoint ||
record?.at !== synced.at
)
return;
if (answer.state === 'removed')
await this.#dropRemoved(worker, now);
else await this.#recordOptIn(false);
});
return true;
}
if (answer?.state !== 'active') return false;
if (
answer.publicKey !== null &&
!(await usesPuterKey(subscription, answer.publicKey))
)
return false;
this.#workerRegistered = true;
return true;
}
/** Has the browser look for a newer worker script, as registering does. */
#checkWorkerUpdate(worker) {
try {
void globalThis.navigator.serviceWorker
.getRegistration(worker.scope ?? worker.url)
.then((registration) => registration?.update())
.catch(() => {});
} catch {
// No service workers here.
}
}
/** Remembers the subscription the server confirmed, for the next loads. */
async #noteSynced(worker, endpoint) {
if (this.puter.env !== 'web') return;
try {
const uuid = await this.#userUuid();
if (uuid) {
await idbPut(HANDLERS_STORE, SYNCED_PREFIX + uuid, {
endpoint,
at: Date.now(),
});
}
await idbDelete(
HANDLERS_STORE,
SUBSCRIPTION_CHANGED_PREFIX + workerScope(worker),
);
} catch {
// The next load registers again.
}
}
/** Ends a subscription left here without an opt-in, in the browser only. */
async #endUnwanted() {
await withPushLock(async () => {
@@ -7,6 +7,12 @@ export const CLICKS_STORE = 'clicks';
// stays at the version workers already installed open.
const OPT_IN_PREFIX = 'puter:opt-in:';
export const SERVER_KEY_ENTRY = 'puter:server-key';
/** Every key the server named as Puter's, the current one first. */
export const SERVER_KEYS_ENTRY = 'puter:server-keys';
/** Per user: `{ endpoint, at }` of the subscription the server last confirmed. */
export const SYNCED_PREFIX = 'puter:synced:';
/** Per worker scope: set by `push-sw.js` when a subscription is replaced. */
export const SUBSCRIPTION_CHANGED_PREFIX = 'puter:subscription-changed:';
/** Where a tap without a URL goes, per worker scope; read by `push-sw.js`. */
export const TAP_BASE_PREFIX = 'puter:tap-base:';
/** How long a kept tap waits for its page, as in `push-sw.js`. */
@@ -55,13 +61,17 @@ export const setOptIn = (userUuid, on, idb = globalThis.indexedDB) =>
? idbPut(HANDLERS_STORE, OPT_IN_PREFIX + userUuid, true, idb)
: idbDelete(HANDLERS_STORE, OPT_IN_PREFIX + userUuid, idb);
/** Forgets every user's opt-in and every kept tap. */
/** Forgets every user's opt-in and sync, and every kept tap. */
export const forgetPushState = async (idb = globalThis.indexedDB) => {
await run(idb, HANDLERS_STORE, 'readwrite', (s) =>
s.delete(
globalThis.IDBKeyRange.bound(OPT_IN_PREFIX, `${OPT_IN_PREFIX}￿`),
),
);
for (const prefix of [
OPT_IN_PREFIX,
SYNCED_PREFIX,
SUBSCRIPTION_CHANGED_PREFIX,
]) {
await run(idb, HANDLERS_STORE, 'readwrite', (s) =>
s.delete(globalThis.IDBKeyRange.bound(prefix, `${prefix}￿`)),
);
}
await run(idb, CLICKS_STORE, 'readwrite', (s) => s.clear());
};
@@ -2,6 +2,7 @@ import { PuterJSError } from '../../../lib/PuterJSError.js';
import { request } from './api.js';
import {
HANDLERS_STORE,
SERVER_KEYS_ENTRY,
SERVER_KEY_ENTRY,
idbGet,
idbPut,
@@ -242,6 +243,17 @@ const turnedOff = () =>
'push_verification_canceled',
);
/** The keys in `values` that decode, skipping anything else. */
const decodeKeys = (values) =>
values.flatMap((value) => {
if (typeof value !== 'string' || value === '') return [];
try {
return [fromBase64Url(value)];
} catch {
return [];
}
});
/** The key Puter last subscribed this origin with, or null. */
const storedPuterKey = async () => {
try {
@@ -252,29 +264,43 @@ const storedPuterKey = async () => {
}
};
/** Whether `buffer` is the key Puter last subscribed this origin with. */
const earlierPuterKey = async (buffer) => {
const stored = await storedPuterKey();
return !!stored && sameKey(buffer, stored);
/** That key and every key the server listed as Puter's. */
const storedPuterKeys = async () => {
let listed = [];
try {
const stored = await idbGet(HANDLERS_STORE, SERVER_KEYS_ENTRY);
if (Array.isArray(stored)) listed = decodeKeys(stored);
} catch {
// Only the last key is known then.
}
const last = await storedPuterKey();
return last ? [last, ...listed] : listed;
};
/** Puter's key as stored here, else as the server gives it, else null. */
const puterKey = async (puter, authToken) => {
const stored = await storedPuterKey();
if (stored) return stored;
/** Whether `buffer` is a key Puter subscribed with: stored here, or in `listed`. */
const earlierPuterKey = async (buffer, listed = []) =>
[...listed, ...(await storedPuterKeys())].some((key) =>
sameKey(buffer, key),
);
/** Puter's keys as stored here, else as the server gives them, else none. */
const puterKeys = async (puter, authToken) => {
const stored = await storedPuterKeys();
if (stored.length > 0) return stored;
try {
const { publicKey } = await request(
const { publicKey, previousKeys } = await request(
puter,
'GET',
'/push/web/key',
undefined,
{ authToken, interactiveReauth: false },
);
return typeof publicKey === 'string' && publicKey !== ''
? fromBase64Url(publicKey)
: null;
return decodeKeys([
publicKey,
...(Array.isArray(previousKeys) ? previousKeys : []),
]);
} catch {
return null;
return [];
}
};
@@ -290,16 +316,23 @@ const puterSubscriptions = async (puter, { worker, authToken, nav }) => {
).filter(Boolean);
// The GUI's own worker carries Puter's subscription only.
if (subscriptions.length === 0 || puter.env === 'gui') return subscriptions;
const key = await puterKey(puter, authToken);
if (!key) return [];
const keys = await puterKeys(puter, authToken);
return subscriptions.filter((subscription) =>
sameKey(subscription.options?.applicationServerKey, key),
keys.some((key) =>
sameKey(subscription.options?.applicationServerKey, key),
),
);
};
const rememberPuterKey = async (publicKey) => {
const rememberPuterKey = async (publicKey, previousKeys) => {
try {
await idbPut(HANDLERS_STORE, SERVER_KEY_ENTRY, publicKey);
if (Array.isArray(previousKeys)) {
await idbPut(HANDLERS_STORE, SERVER_KEYS_ENTRY, [
publicKey,
...previousKeys.filter((k) => typeof k === 'string'),
]);
}
} catch {
// Only a later key rotation needs it.
}
@@ -310,11 +343,15 @@ const rememberPuterKey = async (publicKey) => {
* with Puter's earlier key is ended; on a website, one made with any other key
* belongs to something else on the site and is left alone.
*/
const currentSubscription = async (puter, registration, key) => {
const currentSubscription = async (puter, registration, key, earlier) => {
const subscription = await registration.pushManager.getSubscription();
const existing = subscription?.options?.applicationServerKey;
if (!subscription || sameKey(existing, key)) return subscription;
if (puter.env === 'web' && existing && !(await earlierPuterKey(existing))) {
if (
puter.env === 'web' &&
existing &&
!(await earlierPuterKey(existing, earlier))
) {
throw new PuterJSError(
'This browser already has a push subscription from another service on this site',
'push_subscription_conflict',
@@ -329,7 +366,15 @@ const register = async (
puter,
nav,
subscribe,
{ label, onPending, signal, interactive, whileWanted, onAnnounced },
{
label,
onPending,
signal,
interactive,
whileWanted,
onAnnounced,
onSynced,
},
) => {
/** @type {ReturnType<typeof nextChallenge> | null} */
let challenge = null;
@@ -346,14 +391,16 @@ const register = async (
);
if (signal?.aborted) throw turnedOff();
await onAnnounced?.();
if (result.status === 'active')
await onSynced?.(subscription.endpoint);
return { subscription, result };
});
if (result.status === 'active') return result.device;
onPending?.();
const answer = await challenge.promise;
if (answer === null) throw turnedOff();
const { device } = await whileWanted(() =>
request(
const { device } = await whileWanted(async () => {
const verified = await request(
puter,
'POST',
'/push/web/subscriptions/verify',
@@ -362,8 +409,10 @@ const register = async (
challenge: answer,
},
{ interactiveReauth: false },
),
);
);
await onSynced?.(subscription.endpoint);
return verified;
});
return device;
} finally {
challenge?.cancel();
@@ -387,6 +436,7 @@ const register = async (
* stillWanted?: () => boolean | Promise<boolean>;
* withLock?: <T>(run: () => Promise<T>) => Promise<T>;
* onAnnounced?: () => Promise<void>;
* onSynced?: (endpoint: string) => Promise<void>;
* }} options
* `onPending` runs once the server has the subscription and only its
* confirmation is left. Aborting `signal` stops waiting for that
@@ -412,9 +462,10 @@ export async function syncWebPush(
stillWanted = () => true,
withLock = (run) => run(),
onAnnounced,
onSynced,
},
) {
const { publicKey } = await request(
const { publicKey, previousKeys } = await request(
puter,
'GET',
'/push/web/key',
@@ -428,6 +479,7 @@ export async function syncWebPush(
);
}
const key = fromBase64Url(publicKey);
const earlier = decodeKeys(Array.isArray(previousKeys) ? previousKeys : []);
const registration = await registerWorker(nav, worker, {
keepSiteWorker: puter.env !== 'gui',
});
@@ -440,6 +492,7 @@ export async function syncWebPush(
signal,
interactive,
onAnnounced,
onSynced,
whileWanted: (run) =>
withLock(async () => {
if (signal?.aborted || !(await stillWanted()))
@@ -453,9 +506,13 @@ export async function syncWebPush(
nav,
async () => {
subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
(await currentSubscription(
puter,
registration,
key,
earlier,
)) ?? (await subscribeWith(registration, key));
await rememberPuterKey(publicKey, previousKeys);
return subscription;
},
options,
@@ -487,20 +544,45 @@ export async function syncWebPush(
* @returns {Promise<'active' | 'removed' | 'revoked' | 'unknown' | null>}
*/
export async function webPushState(puter, endpoint) {
return (await webPushCheck(puter, endpoint))?.state ?? null;
}
/** `webPushState`'s answer with the key the server names as Puter's, if any. */
export async function webPushCheck(puter, endpoint) {
try {
const { state } = await request(
const { state, publicKey } = await request(
puter,
'POST',
'/push/web/subscriptions/status',
{ endpoint },
{ interactiveReauth: false },
);
return SUBSCRIPTION_STATES.has(state) ? state : null;
if (!SUBSCRIPTION_STATES.has(state)) return null;
return {
state,
publicKey:
typeof publicKey === 'string' && publicKey !== ''
? publicKey
: null,
};
} catch {
return null;
}
}
/** Whether `subscription` uses Puter's stored key, and `publicKey` if given. */
export async function usesPuterKey(subscription, publicKey = null) {
const existing = subscription?.options?.applicationServerKey;
const stored = await storedPuterKey();
if (!stored || !sameKey(existing, stored)) return false;
if (publicKey === null) return true;
try {
return sameKey(existing, fromBase64Url(publicKey));
} catch {
return false;
}
}
/** Ends Puter's subscriptions in the browser only; resolves their endpoints. */
export async function unsubscribeWebPush(
puter,
@@ -8,7 +8,8 @@ vi.mock('../../lib/networkUtils.js', () => ({
}));
const { NotificationsModule } = await import('./index.js');
const { webPushState } = await import('./lib/webPush.js');
const { webPushCheck, webPushState } = await import('./lib/webPush.js');
const { forgetPushState } = await import('./lib/pushStore.js');
const { request } = await import('./lib/api.js');
const { PuterJSError } = await import('../../lib/PuterJSError.js');
@@ -1514,6 +1515,67 @@ describe('webPushState', () => {
});
});
describe('webPushCheck', () => {
const host = { APIOrigin: 'https://api.test' };
it('gives the key the server names along with the state', async () => {
mockFetchUrl.mockResolvedValue(
jsonResponse({ state: 'active', publicKey: 'BKey' }),
);
await expect(
webPushCheck(host, 'https://push.example/1'),
).resolves.toEqual({ state: 'active', publicKey: 'BKey' });
});
it('gives no key for a server that names none', async () => {
mockFetchUrl.mockResolvedValue(jsonResponse({ state: 'active' }));
await expect(
webPushCheck(host, 'https://push.example/1'),
).resolves.toEqual({ state: 'active', publicKey: null });
});
});
describe('forgetPushState', () => {
it('forgets opt-ins, confirmed syncs and replaced-subscription marks, and every kept tap', async () => {
const calls = [];
const request = (call) => {
calls.push(call);
const req = {};
queueMicrotask(() => req.onsuccess?.());
return req;
};
const db = {
transaction: (store) => ({
objectStore: () => ({
delete: (range) => request(['delete', store, range]),
clear: () => request(['clear', store]),
}),
}),
close: () => {},
};
const idb = {
open: () => {
const req = { result: db };
queueMicrotask(() => req.onsuccess());
return req;
},
};
const previous = globalThis.IDBKeyRange;
globalThis.IDBKeyRange = { bound: (lower) => lower };
try {
await forgetPushState(idb);
} finally {
globalThis.IDBKeyRange = previous;
}
expect(calls).toEqual([
['delete', 'handlers', 'puter:opt-in:'],
['delete', 'handlers', 'puter:synced:'],
['delete', 'handlers', 'puter:subscription-changed:'],
['clear', 'clicks'],
]);
});
});
describe('request with keepalive', () => {
it('sends through fetch with keepalive and the given token, never through fetchUrl', async () => {
const fetch = vi.fn(async () => jsonResponse({ removed: true }));
@@ -18,6 +18,9 @@ vi.mock('./lib/pushStore.js', () => {
HANDLERS_STORE: 'handlers',
CLICKS_STORE: 'clicks',
SERVER_KEY_ENTRY: 'puter:server-key',
SERVER_KEYS_ENTRY: 'puter:server-keys',
SYNCED_PREFIX: 'puter:synced:',
SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:',
TAP_BASE_PREFIX: 'puter:tap-base:',
CLICK_KEEP_MS: 5 * 60 * 1000,
takeClick: stored(async (uid, id) => {
@@ -43,8 +46,14 @@ vi.mock('./lib/pushStore.js', () => {
: void store.delete(optIn(uuid)),
),
forgetPushState: stored(async () => {
const forgotten = [
optIn(''),
'handlers:puter:synced:',
'handlers:puter:subscription-changed:',
'clicks:',
];
for (const key of [...store.keys()]) {
if (key.startsWith(optIn('')) || key.startsWith('clicks:'))
if (forgotten.some((prefix) => key.startsWith(prefix)))
store.delete(key);
}
}),
@@ -283,6 +292,46 @@ describe('syncWebPush', () => {
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
});
describe('with the earlier keys the server lists', () => {
const listing = () =>
mockRequest.mockImplementation(async (_p, method, route) =>
route === '/push/web/key'
? { publicKey: KEY, previousKeys: [OLD_KEY] }
: { status: 'active', device: { uid: 'd1' } },
);
it('replaces a subscription made with one, though none is stored here', async () => {
const stale = makeSubscription(fromBase64Url(OLD_KEY));
const nav = makeNav({ existing: stale });
listing();
await syncWebPush(puter, { worker, nav });
expect(stale.unsubscribe).toHaveBeenCalled();
expect(nav.pushManager.subscribe).toHaveBeenCalled();
expect(store.get(SERVER_KEY)).toBe(KEY);
expect(store.get('handlers:puter:server-keys')).toEqual([
KEY,
OLD_KEY,
]);
});
it('still leaves a subscription made with a key it doesn`t list', async () => {
const theirs = makeSubscription(new Uint8Array([4, 1, 2]));
const nav = makeNav({ existing: theirs });
listing();
await expect(
syncWebPush(puter, { worker, nav }),
).rejects.toMatchObject({ code: 'push_subscription_conflict' });
expect(theirs.unsubscribe).not.toHaveBeenCalled();
});
it('stores none from a server that lists none', async () => {
const nav = makeNav({ existing: makeSubscription() });
server();
await syncWebPush(puter, { worker, nav });
expect(store.has('handlers:puter:server-keys')).toBe(false);
});
});
it('replaces any other subscription on the GUI`s own worker', async () => {
const stale = makeSubscription(new Uint8Array([4, 1, 2]));
const nav = makeNav({
@@ -795,6 +844,30 @@ describe('removeWebPush', () => {
expect(theirs.unsubscribe).not.toHaveBeenCalled();
});
it('without a worker, also ends one made with a key the server listed as Puter`s', async () => {
store.set(SERVER_KEY, KEY);
store.set('handlers:puter:server-keys', [KEY, OLD_KEY]);
const theirs = makeSubscription(theirKey, 'https://push.example/site');
const older = makeSubscription(fromBase64Url(OLD_KEY));
const nav = navWith({ '/': theirs, '/push/': older });
mockRequest.mockResolvedValue({ removed: true });
await expect(removeWebPush(puter, { nav })).resolves.toBe(true);
expect(older.unsubscribe).toHaveBeenCalled();
expect(theirs.unsubscribe).not.toHaveBeenCalled();
});
it('takes the earlier keys from the server when none is stored', async () => {
const older = makeSubscription(fromBase64Url(OLD_KEY));
const nav = navWith({ '/push/': older });
mockRequest.mockImplementation(async (_p, method, route) =>
route === '/push/web/key'
? { publicKey: KEY, previousKeys: [OLD_KEY] }
: { removed: true },
);
await expect(removeWebPush(puter, { nav })).resolves.toBe(true);
expect(older.unsubscribe).toHaveBeenCalled();
});
it('touches nothing when Puter`s key can`t be told', async () => {
const subscription = makeSubscription();
const nav = makeNav({ existing: subscription });
@@ -1361,6 +1434,255 @@ describe('onPush', () => {
});
});
describe('on load after the server confirmed this browser', () => {
const ENDPOINT = 'https://push.example/send/abc';
const SYNCED = 'handlers:puter:synced:u-1';
const CHANGED = `handlers:puter:subscription-changed:${ORIGIN}/`;
const NEW_KEY = Buffer.from([4, ...Array(64).fill(5)]).toString(
'base64url',
);
const routes = () => mockRequest.mock.calls.map(([, , r]) => r);
const confirmed = (over = {}) => {
store.set(OPT_IN, true);
store.set(SERVER_KEY, KEY);
store.set(SYNCED, { endpoint: ENDPOINT, at: Date.now(), ...over });
};
const statusSays = (answer) =>
mockRequest.mockImplementation(async (_p, _m, route) => {
if (route === '/push/web/subscriptions/status') {
if (answer instanceof Error) throw answer;
return answer;
}
if (route === '/push/web/key') return { publicKey: KEY };
return { status: 'active', device: { uid: 'd1' } };
});
const load = async () => {
const { module } = makeModule();
await module.onPush('chat', () => {}, { onError: () => {} });
for (let i = 0; i < 5; i++) await flush();
return module;
};
it.each([
['a server that names no key', { state: 'active' }],
[
'a server that names the same key',
{ state: 'active', publicKey: KEY },
],
])(
'only asks the server about the subscription, on %s',
async (_label, answer) => {
confirmed();
withBrowser(makeNav({ existing: makeSubscription() }));
statusSays(answer);
await load();
expect(routes()).toEqual(['/push/web/subscriptions/status']);
},
);
it('has the browser look for a newer worker script, without waiting for it', async () => {
confirmed();
const nav = makeNav({ existing: makeSubscription() });
nav.registration.update = vi.fn(() => new Promise(() => {}));
withBrowser(nav);
statusSays({ state: 'active' });
await load();
expect(nav.registration.update).toHaveBeenCalledTimes(1);
expect(routes()).toEqual(['/push/web/subscriptions/status']);
});
it('ends a subscription the user removed in Puter, after only asking', async () => {
confirmed();
const subscription = makeSubscription();
const nav = makeNav({ existing: subscription });
withBrowser(nav);
statusSays({ state: 'removed' });
await load();
expect(routes()).toEqual(['/push/web/subscriptions/status']);
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1);
expect(store.has(OPT_IN)).toBe(false);
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
});
it('keeps pushes off once the user turned the app off in Puter, after only asking', async () => {
confirmed();
const subscription = makeSubscription();
withBrowser(makeNav({ existing: subscription }));
statusSays({ state: 'revoked' });
await load();
expect(routes()).toEqual(['/push/web/subscriptions/status']);
expect(store.has(OPT_IN)).toBe(false);
expect(subscription.unsubscribe).not.toHaveBeenCalled();
});
it.each([
['removed', 'a new subscription'],
['revoked', 'a new subscription'],
['removed', 'a new confirmation'],
])(
'leaves things alone when the server says %s but another tab`s enable() made %s meanwhile',
async (state, change) => {
confirmed();
const stale = makeSubscription();
const fresh = makeSubscription(
fromBase64Url(KEY),
'https://push.example/send/new',
);
const nav = makeNav({ existing: stale });
withBrowser(nav);
let answer;
mockRequest.mockImplementation(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
await load();
expect(routes()).toEqual(['/push/web/subscriptions/status']);
if (change === 'a new subscription') {
nav.pushManager.getSubscription.mockResolvedValue(fresh);
store.set(SYNCED, {
endpoint: fresh.endpoint,
at: Date.now() + 1,
});
} else {
store.set(SYNCED, {
endpoint: ENDPOINT,
at: Date.now() + 1,
});
}
answer({ state });
for (let i = 0; i < 5; i++) await flush();
expect(stale.unsubscribe).not.toHaveBeenCalled();
expect(fresh.unsubscribe).not.toHaveBeenCalled();
expect(store.get(OPT_IN)).toBe(true);
},
);
it.each([
[
'a sync over a day old',
{ at: Date.now() - 25 * 60 * 60 * 1000 },
{ state: 'active' },
],
[
'another subscription',
{ endpoint: 'https://push.example/send/other' },
{ state: 'active' },
],
['an unknown subscription', {}, { state: 'unknown' }],
[
'a server that can`t tell',
{},
new PuterJSError('Not Found', 'not_found'),
],
['a new key', {}, { state: 'active', publicKey: NEW_KEY }],
])('registers again after %s', async (_label, over, answer) => {
confirmed(over);
withBrowser(makeNav({ existing: makeSubscription() }));
statusSays(answer);
await load();
expect(routes()).toEqual(
expect.arrayContaining([
'/push/web/key',
'/push/web/subscriptions',
]),
);
expect(store.get(SYNCED).at).toBeGreaterThan(
Date.now() - 60 * 1000,
);
});
it('remembers a confirmed subscription, so the next load only asks', async () => {
store.set(OPT_IN, true);
withBrowser(makeNav({ existing: makeSubscription() }));
statusSays({ state: 'active' });
await load();
expect(routes()).toEqual([
'/push/web/key',
'/push/web/subscriptions',
]);
expect(store.get(SYNCED)).toEqual({
endpoint: ENDPOINT,
at: expect.any(Number),
});
mockRequest.mockClear();
await load();
expect(routes()).toEqual(['/push/web/subscriptions/status']);
});
it('remembers a subscription once its confirmation is verified', async () => {
store.set(OPT_IN, true);
const nav = makeNav();
withBrowser(nav);
server((route) =>
route === '/push/web/subscriptions'
? { status: 'pending' }
: { device: { uid: 'd2' } },
);
await load();
expect(store.has(SYNCED)).toBe(false);
nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' });
await vi.waitFor(() =>
expect(store.get(SYNCED)?.endpoint).toBe(ENDPOINT),
);
});
it('registers again, and forgets the mark, after the worker saw the browser replace the subscription', async () => {
confirmed();
store.set(CHANGED, 1);
withBrowser(makeNav({ existing: makeSubscription() }));
statusSays({ state: 'active' });
await load();
expect(routes()).toEqual([
'/push/web/key',
'/push/web/subscriptions',
]);
expect(store.has(CHANGED)).toBe(false);
});
it('registers in full when the worker says the browser replaced the subscription', async () => {
confirmed();
const nav = makeNav({ existing: makeSubscription() });
withBrowser(nav);
statusSays({ state: 'active' });
await load();
expect(routes()).toEqual(['/push/web/subscriptions/status']);
nav.post({ type: 'puter-push-subscription-changed' });
await vi.waitFor(() =>
expect(routes()).toEqual([
'/push/web/subscriptions/status',
'/push/web/key',
'/push/web/subscriptions',
]),
);
});
it('lets enable() register in full', async () => {
confirmed();
withBrowser(makeNav({ existing: makeSubscription() }));
statusSays({ state: 'active' });
const { module } = makeModule();
await expect(module.enable()).resolves.toBe(true);
expect(routes()).toEqual([
'/push/web/key',
'/push/web/subscriptions',
]);
});
it('is forgotten by disable()', async () => {
confirmed();
store.set(CHANGED, 1);
withBrowser(makeNav({ existing: makeSubscription() }));
mockRequest.mockResolvedValue({ removed: true });
const { module } = makeModule();
await module.disable();
expect(store.has(SYNCED)).toBe(false);
expect(store.has(CHANGED)).toBe(false);
});
});
describe('live delivery while the user is elsewhere', () => {
let nav;
const page = ({ visibilityState = 'visible', focused = true } = {}) => {
+49 -1
View File
@@ -373,9 +373,57 @@
event.waitUntil(Promise.all([routeClick(puter, startedAt), forgetOldClicksQuietly()]));
});
// Entries pushStore.js writes.
const OPT_IN_PREFIX = 'puter:opt-in:';
const SERVER_KEY = 'puter:server-key';
const SERVER_KEYS = 'puter:server-keys';
const CHANGED_PREFIX = 'puter:subscription-changed:';
const fromBase64Url = (value) => {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
};
function sameKey (buffer, value) {
if ( ! buffer ) return false;
try {
const bytes = new Uint8Array(buffer);
const key = fromBase64Url(value);
return bytes.length === key.length && bytes.every((b, i) => b === key[i]);
} catch {
return false;
}
}
async function puterKeys () {
const last = await idb(HANDLERS, 'readonly', (s) => s.get(SERVER_KEY));
const listed = await idb(HANDLERS, 'readonly', (s) => s.get(SERVER_KEYS));
return [last, ...(Array.isArray(listed) ? listed : [])].filter((key) => str(key));
}
// No Puter session here: keep Puter's subscription and mark it for the next page load to register.
async function keepSubscription (event) {
const optIns = await idb(HANDLERS, 'readonly', (s) => s.count(IDBKeyRange.bound(OPT_IN_PREFIX, `${OPT_IN_PREFIX}\uffff`)));
if ( ! optIns ) return;
const keys = await puterKeys();
const old = event.oldSubscription?.options?.applicationServerKey;
if ( old && ! keys.some((key) => sameKey(old, key)) ) return;
await idb(HANDLERS, 'readwrite', (s) => s.put(Date.now(), CHANGED_PREFIX + self.registration.scope));
if ( event.newSubscription || ! keys.length ) return;
if ( await self.registration.pushManager.getSubscription() ) return;
// Without the old subscription, only a worker a Puter page used (it saved a tap base) subscribes.
if ( ! old && ! await idb(HANDLERS, 'readonly', (s) => s.get(TAP_BASE_PREFIX + self.registration.scope)) ) return;
await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: fromBase64Url(keys[0]) });
}
self.addEventListener('pushsubscriptionchange', (event) => {
// The worker has no Puter session; an open page registers the new one.
event.waitUntil((async () => {
try {
await keepSubscription(event);
} catch ( err ) {
console.warn('[puter] could not keep the push subscription', err);
}
for ( const win of await windows() ) win.postMessage({ type: CHANGED_MESSAGE });
})());
});
+94
View File
@@ -34,6 +34,13 @@ const fakeIndexedDB = (stores) => {
get: (key) => request(() => map.get(key)),
put: (value, key) => request(() => void map.set(key, value)),
delete: (key) => request(() => void map.delete(key)),
count: (range) =>
request(
() =>
[...map.keys()].filter(
(key) => key >= range.lower && key <= range.upper,
).length,
),
openCursor: () => {
const keys = [...map.keys()];
const cursorAt = (req, i) =>
@@ -84,6 +91,7 @@ const loadWorker = ({
windows = [],
matchAllDelayMs = 0,
scope: registrationScope = `${ORIGIN}/`,
subscription = null,
} = {}) => {
const listeners = {};
const shown = [];
@@ -103,6 +111,10 @@ const loadWorker = ({
showNotification: vi.fn(async (title, options) => {
shown.push({ title, options });
}),
pushManager: {
getSubscription: vi.fn(async () => subscription),
subscribe: vi.fn(async () => ({ endpoint: 'https://push.example/new' })),
},
},
clients: {
matchAll: vi.fn(async () => {
@@ -130,6 +142,8 @@ const loadWorker = ({
Number,
Function,
indexedDB: fakeIndexedDB(stores),
IDBKeyRange: { bound: (lower, upper) => ({ lower, upper }) },
atob,
});
context.self = context;
vm.runInContext(SOURCE, context);
@@ -155,6 +169,10 @@ const loadWorker = ({
const pushData = (payload) => ({ json: () => payload });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TAP_BASE = 'puter:tap-base:';
// 65 bytes, 0x04 first, as base64url.
const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url');
const OLD_KEY = Buffer.from([4, ...Array(64).fill(3)]).toString('base64url');
const CHANGED = `puter:subscription-changed:${ORIGIN}/`;
describe('push-sw push', () => {
it('leaves a push without a Puter message to the app', async () => {
@@ -597,3 +615,79 @@ describe('push-sw notifications a page shows', () => {
expect(worker.shown).toEqual([]);
});
});
describe('push-sw pushsubscriptionchange', () => {
const keyOf = (value) => ({ options: { applicationServerKey: new Uint8Array(Buffer.from(value, 'base64url')).buffer } });
const optedIn = { 'puter:opt-in:u-1': true, 'puter:server-key': KEY };
it('marks the change and subscribes again with Puter`s key when the browser gave no new subscription', async () => {
const win = windowClient(`${ORIGIN}/`);
const worker = loadWorker({ handlers: optedIn, windows: [win] });
await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(KEY), newSubscription: null });
expect(worker.stores.handlers.get(CHANGED)).toEqual(expect.any(Number));
const { subscribe } = worker.scope.registration.pushManager;
expect(subscribe).toHaveBeenCalledTimes(1);
expect([...subscribe.mock.calls[0][0].applicationServerKey]).toEqual([...Buffer.from(KEY, 'base64url')]);
expect(subscribe.mock.calls[0][0].userVisibleOnly).toBe(true);
expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' });
});
it('keeps the new subscription the browser gave', async () => {
const worker = loadWorker({ handlers: optedIn });
await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(KEY), newSubscription: { endpoint: 'https://push.example/2' } });
expect(worker.stores.handlers.has(CHANGED)).toBe(true);
expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled();
});
it('subscribes with the current key after one the server listed as earlier', async () => {
const worker = loadWorker({ handlers: { ...optedIn, 'puter:server-keys': [KEY, OLD_KEY] } });
await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(OLD_KEY) });
expect(worker.stores.handlers.has(CHANGED)).toBe(true);
const [[options]] = worker.scope.registration.pushManager.subscribe.mock.calls;
expect([...options.applicationServerKey]).toEqual([...Buffer.from(KEY, 'base64url')]);
});
it('only tells the open windows when nobody turned push on here', async () => {
const win = windowClient(`${ORIGIN}/`);
const worker = loadWorker({ handlers: { 'puter:server-key': KEY }, windows: [win] });
await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(KEY) });
expect(worker.stores.handlers.has(CHANGED)).toBe(false);
expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled();
expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' });
});
it('leaves another service`s subscription to it', async () => {
const worker = loadWorker({ handlers: optedIn });
await worker.fire('pushsubscriptionchange', { oldSubscription: keyOf(Buffer.from([4, 1, 2]).toString('base64url')) });
expect(worker.stores.handlers.has(CHANGED)).toBe(false);
expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled();
});
it('without the old subscription, subscribes only a worker a Puter page used', async () => {
const unused = loadWorker({ handlers: optedIn });
await unused.fire('pushsubscriptionchange', {});
expect(unused.stores.handlers.has(CHANGED)).toBe(true);
expect(unused.scope.registration.pushManager.subscribe).not.toHaveBeenCalled();
const used = loadWorker({ handlers: { ...optedIn, [`${TAP_BASE}${ORIGIN}/`]: `${ORIGIN}/` } });
await used.fire('pushsubscriptionchange', {});
expect(used.scope.registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
});
it('subscribes nothing when the browser already has a subscription again', async () => {
const worker = loadWorker({ handlers: optedIn, subscription: { endpoint: 'https://push.example/3' } });
await worker.fire('pushsubscriptionchange', {});
expect(worker.stores.handlers.has(CHANGED)).toBe(true);
expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled();
});
it('still tells the open windows when IndexedDB fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const win = windowClient(`${ORIGIN}/`);
const worker = loadWorker({ handlers: optedIn, windows: [win] });
worker.stores.broken = true;
await worker.fire('pushsubscriptionchange', {});
expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' });
warn.mockRestore();
});
});