mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925b8993da | ||
|
|
10a432eb85 | ||
|
|
76fd962673 | ||
|
|
b1965828ab | ||
|
|
9ded4d5687 | ||
|
|
3dea2d9234 | ||
|
|
731ad5fa3f | ||
|
|
099eff2778 | ||
|
|
3d0f43658d | ||
|
|
d73dab8330 | ||
|
|
6807c74ca7 | ||
|
|
6c8090cd79 | ||
|
|
c93799f85d | ||
|
|
43412cc7a4 | ||
|
|
1c25e508c9 | ||
|
|
c8b3d5fc8d | ||
|
|
263f59202d | ||
|
|
d7bf80d060 | ||
|
|
b5c0cf8b02 | ||
|
|
a5274d0bd8 | ||
|
|
10e8ca294b | ||
|
|
b5adc00819 | ||
|
|
2a0a709e82 | ||
|
|
23e13cbf42 | ||
|
|
6c997e8e42 | ||
|
|
17aba4cf43 | ||
|
|
fbe91051c1 | ||
|
|
7274b5992d | ||
|
|
4381da5187 | ||
|
|
9421a8f928 | ||
|
|
f74412669d | ||
|
|
d9fd40daed | ||
|
|
e15c242f5a | ||
|
|
6d3b49d1e4 | ||
|
|
e069ac68c3 | ||
|
|
7b594d662f | ||
|
|
0b2e54f6c7 | ||
|
|
30b1117ead | ||
|
|
7da158b571 |
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1455,6 +1456,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 });
|
||||
@@ -1307,6 +1380,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 } = {}) => {
|
||||
|
||||
@@ -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 });
|
||||
})());
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user