mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-03 20:43:22 +00:00
fix(notifications): a browser removed in Puter stays off, and status() checks with Puter on websites
This commit is contained in:
1 parent
f8dc10268e
commit
0cb45624bd
12 files changed
+425
-67
No files matched your search
@@ -10,7 +10,7 @@ On a website, the browser asks on your own origin, and pushes arrive through you
|
||||
|
||||
The call resolves as soon as the browser has answered and Puter has its subscription. It doesn't wait for the confirmation. Pushes start once your page hands the confirmation back, usually within seconds. If the confirmation doesn't arrive within 30 seconds, the `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout`, or the console does without one, and the next page load tries again.
|
||||
|
||||
The choice sticks. Once enabled, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) again from a click.
|
||||
The choice sticks. Once enabled, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) again from a click.
|
||||
|
||||
If the user turned your app off in Puter's settings, the call rejects with `push_revoked`. To ask again, call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call [`enable()`](/Notifications/enable/) again.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Handles the notifications your app sends with `channel: key` in [`notify()`](/No
|
||||
|
||||
A page with a handler tells Puter when it receives a notification, before your handler runs. When the page is visible and focused, nothing is pushed. When it's hidden or its window isn't focused, the page shows the notification itself, unless another tab of your site is focused. It shows the handler's result, or the notification's own title and text when no handler matches the notification's `channel`. Showing it needs the browser's notification permission, which [`enable()`](/Notifications/enable/) asks for. Otherwise, when no page answers within 5 seconds that it's visible and focused or that it shows the notification itself, Puter sends a push. [Push notifications for your app](/Notifications/push/#when-a-notification-is-pushed) has the full rule.
|
||||
|
||||
The user turns pushes on once with [`enable()`](/Notifications/enable/). After that, each page load's [`onPush()`](/Notifications/onPush/) call keeps this browser's subscription current without asking again. Different keys share that one permission. After [`disable()`](/Notifications/disable/), after the user turns your app off in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
|
||||
The user turns pushes on once with [`enable()`](/Notifications/enable/). After that, each page load's [`onPush()`](/Notifications/onPush/) call keeps this browser's subscription current without asking again. Different keys share that one permission. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
|
||||
|
||||
## The service worker
|
||||
|
||||
@@ -50,7 +50,7 @@ A [sensitive](/Notifications/notify/#parameters) push carries no `channel`, so n
|
||||
|
||||
#### `options` (Object) (optional)
|
||||
- `serviceWorker` (String): path of the worker that imports `push-sw.js`. Default `/puter-sw.js`.
|
||||
- `onError` (Function): called when the handler throws in the page or runs past 5 seconds there (`push_handler_timeout`, and the notification's own title and text show instead), the live listener stops, keeping the subscription current fails, or the confirmation push after [`enable()`](/Notifications/enable/) doesn't arrive within 30 seconds (`push_verification_timeout`). Without it, errors go to the console. A server without a push service (`push_unavailable`) and an app the user turned off in Puter (`push_revoked`) are not reported here, since [`enable()`](/Notifications/enable/) rejects with them when called.
|
||||
- `onError` (Function): called when the handler throws in the page or runs past 5 seconds there (`push_handler_timeout`, and the notification's own title and text show instead), the live listener stops, keeping the subscription current fails, or the confirmation push after [`enable()`](/Notifications/enable/) doesn't arrive within 30 seconds (`push_verification_timeout`). Without it, errors go to the console. A server without a push service (`push_unavailable`) and an app the user turned off in Puter (`push_revoked`) are not reported here, since [`enable()`](/Notifications/enable/) rejects with them when called. A browser the user removed in Puter isn't reported either, since [`enable()`](/Notifications/enable/) turns pushes back on there.
|
||||
|
||||
## Handler limits
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ document.getElementById('enable').addEventListener('click', async () => {
|
||||
|
||||
When nobody is signed in, [`enable()`](/Notifications/enable/) opens the Puter sign-in after the browser's prompt. Sign the user in first, from its own button, so the sign-in window isn't blocked as a popup.
|
||||
|
||||
To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Notifications still reach your app live while it's open. Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Call [`enable()`](/Notifications/enable/) from a click to turn them on again. Signing out also removes this browser's registration for your app.
|
||||
To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Notifications still reach your app live while it's open. Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Call [`enable()`](/Notifications/enable/) from a click to turn them on again. Signing out also removes this browser's registration for your app.
|
||||
|
||||
## Sending a notification
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ platforms: [websites, apps]
|
||||
|
||||
Removes one of the user's devices, so it gets no more push notifications. Signing out of a session also removes the devices registered from it.
|
||||
|
||||
Removing a `'webpush'` device sticks. The website that subscribed that browser doesn't register it again on page loads. The site turns pushes off in that browser the next time a page with [`onPush()`](/Notifications/onPush/) loads or calls [`status()`](/Notifications/status/). Calling [`enable()`](/Notifications/enable/) on the site turns them back on.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
puter.notifications.removeDevice(uid)
|
||||
|
||||
@@ -4,9 +4,9 @@ description: Check whether push notifications from your app reach the browser th
|
||||
platforms: [websites, apps]
|
||||
---
|
||||
|
||||
Whether your app's push notifications reach the browser the user is in right now. It never prompts and never changes anything, so call it on page load to decide whether to show your own "Turn on notifications" button, which calls [`enable()`](/Notifications/enable/).
|
||||
Whether your app's push notifications reach the browser the user is in right now. It never prompts, so call it on page load to decide whether to show your own "Turn on notifications" button, which calls [`enable()`](/Notifications/enable/).
|
||||
|
||||
On a website, it's `'on'` when the user turned pushes on in this browser with [`enable()`](/Notifications/enable/), and this browser still holds the subscription. Pushes start once Puter confirms the browser, usually within seconds of [`enable()`](/Notifications/enable/) resolving. Inside the Puter desktop, it's `'on'` when pushes are on in this browser and your app holds the [`notifications:push`](/Notifications/requestPermission/) permission.
|
||||
On a website, it's `'on'` when the user turned pushes on in this browser with [`enable()`](/Notifications/enable/), and this browser still holds the subscription. It then checks that subscription with Puter, and it's `'off'` after the user removes this browser in Puter's settings or turns your app off there. Pushes start once Puter confirms the browser, usually within seconds of [`enable()`](/Notifications/enable/) resolving. Inside the Puter desktop, it's `'on'` when pushes are on in this browser and your app holds the [`notifications:push`](/Notifications/requestPermission/) permission.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
@@ -25,11 +25,11 @@ A `Promise` that resolves to one of these strings. It never rejects.
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| `'on'` | Pushes from your app reach this browser. |
|
||||
| `'off'` | They don't yet. Call [`enable()`](/Notifications/enable/) from a click to turn them on. |
|
||||
| `'off'` | They don't yet. Call [`enable()`](/Notifications/enable/) from a click to turn them on. After the user turned your app off in Puter, [`enable()`](/Notifications/enable/) rejects with `push_revoked`. |
|
||||
| `'blocked'` | The browser blocks notifications, for your site on a website or for Puter inside the desktop. The user unblocks them in the browser's site settings. |
|
||||
| `'unavailable'` | This browser or page can't receive pushes, such as a browser without Web Push, or the server has no push notification service. |
|
||||
|
||||
On a website, the call doesn't ask the server, so it answers `'off'` on a server without a push notification service, and [`enable()`](/Notifications/enable/) then rejects with `push_unavailable`. It also answers `'off'` when nobody is signed in.
|
||||
On a website, it answers `'off'` on a server without a push notification service, and [`enable()`](/Notifications/enable/) then rejects with `push_unavailable`. It also answers `'off'` when nobody is signed in. If the check with Puter fails, it answers `'on'` from this browser's subscription alone.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import { webPushSupported, workerFor } from './lib/webPush.js';
|
||||
* and sends the subscription to Puter. It resolves then; Puter's confirmation
|
||||
* push finishes the registration in the background. It rejects with
|
||||
* `push_revoked` when the user turned the app off in Puter;
|
||||
* `requestPermission()` from a click undoes that.
|
||||
* `requestPermission()` from a click undoes that. A browser the user removed in
|
||||
* Puter is subscribed again.
|
||||
*
|
||||
* @this {import('./index.js').NotificationsModule}
|
||||
* @param {{ serviceWorker?: string }} [options]
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
currentWebPush,
|
||||
removeWebPush,
|
||||
syncWebPush,
|
||||
webPushState,
|
||||
webPushSupported,
|
||||
workerFor,
|
||||
} from './lib/webPush.js';
|
||||
@@ -53,6 +54,7 @@ const HANDLER_TIMEOUT_MS = 5_000;
|
||||
const WORKER_SHOW_TIMEOUT_MS = 2_000;
|
||||
/** Sync failures that are the server's or the user's choice, not a fault. */
|
||||
const QUIET_SYNC_ERRORS = new Set([
|
||||
'push_device_removed',
|
||||
'push_disabled',
|
||||
'push_revoked',
|
||||
'push_unavailable',
|
||||
@@ -367,6 +369,7 @@ export class NotificationsModule extends EventListener {
|
||||
worker,
|
||||
onPending: pending,
|
||||
signal,
|
||||
renewRemoved: true,
|
||||
});
|
||||
try {
|
||||
// The GUI reads the registered device right after.
|
||||
@@ -374,7 +377,7 @@ export class NotificationsModule extends EventListener {
|
||||
? syncing
|
||||
: Promise.race([syncing, sent]));
|
||||
} catch (error) {
|
||||
if (error?.code === 'push_revoked') await this.#recordOptIn(false);
|
||||
await this.#followRefusal(error, worker);
|
||||
throw error;
|
||||
}
|
||||
// A disable() that ran meanwhile wins.
|
||||
@@ -388,6 +391,7 @@ export class NotificationsModule extends EventListener {
|
||||
await this.#recordOptIn(true);
|
||||
void this.#finishSync(
|
||||
syncing,
|
||||
worker,
|
||||
'could not confirm this browser for pushes',
|
||||
);
|
||||
}
|
||||
@@ -411,7 +415,8 @@ export class NotificationsModule extends EventListener {
|
||||
|
||||
/**
|
||||
* `status()` on a website or the GUI. On a website, a subscription counts
|
||||
* only while the signed-in user's opt-in is recorded here.
|
||||
* only while the signed-in user's opt-in is recorded here. An `on` is
|
||||
* checked with the server, and kept when the server can't tell.
|
||||
*
|
||||
* @param {{ url: string; scope?: string }} worker
|
||||
* @returns {Promise<import('./types.js').PushStatus>}
|
||||
@@ -428,8 +433,18 @@ export class NotificationsModule extends EventListener {
|
||||
return 'off';
|
||||
}
|
||||
if (!subscription) return 'off';
|
||||
if (this.puter.env !== 'web') return 'on';
|
||||
return (await this.#readOptIn()) ? 'on' : 'off';
|
||||
if (this.puter.env === 'web' && !(await this.#readOptIn()))
|
||||
return 'off';
|
||||
const state = await webPushState(this.puter, subscription.endpoint);
|
||||
if (state === 'removed') {
|
||||
await this.#dropRemoved(worker, subscription);
|
||||
return 'off';
|
||||
}
|
||||
if (state === 'revoked') {
|
||||
await this.#recordOptIn(false);
|
||||
return 'off';
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -566,22 +581,46 @@ export class NotificationsModule extends EventListener {
|
||||
worker,
|
||||
signal: this.#stopVerifying.signal,
|
||||
}),
|
||||
worker,
|
||||
'could not keep the push subscription current',
|
||||
);
|
||||
}
|
||||
|
||||
/** Settles a sync nobody waits on; quiet about the expected failures. */
|
||||
async #finishSync(syncing, what) {
|
||||
async #finishSync(syncing, worker, what) {
|
||||
try {
|
||||
await syncing;
|
||||
this.#workerRegistered = true;
|
||||
} catch (error) {
|
||||
if (error?.code === 'push_revoked') await this.#recordOptIn(false);
|
||||
await this.#followRefusal(error, worker);
|
||||
if (QUIET_SYNC_ERRORS.has(error?.code)) return;
|
||||
this.#reportPush(error, what);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushes stay off here when the user turned them off in Puter. */
|
||||
async #followRefusal(error, worker) {
|
||||
if (error?.code === 'push_device_removed')
|
||||
await this.#dropRemoved(worker);
|
||||
else if (error?.code === 'push_revoked') await this.#recordOptIn(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user removed this browser in Puter: ends its subscription here only,
|
||||
* since the server already dropped it, and forgets the opt-in until
|
||||
* `enable()`.
|
||||
*/
|
||||
async #dropRemoved(worker, subscription = null) {
|
||||
try {
|
||||
await (
|
||||
subscription ?? (await currentWebPush({ worker }))
|
||||
)?.unsubscribe();
|
||||
} catch {
|
||||
// Already gone, or the browser can't tell.
|
||||
}
|
||||
await this.#recordOptIn(false);
|
||||
}
|
||||
|
||||
#cancelVerification() {
|
||||
this.#stopVerifying.abort();
|
||||
this.#stopVerifying = new AbortController();
|
||||
|
||||
@@ -8,14 +8,22 @@ const UNAVAILABLE_MESSAGE =
|
||||
* One `/push` call; the server's `{ message, code }` passes through. A 404 the
|
||||
* device routes didn't answer themselves becomes `push_unavailable`. With
|
||||
* `authToken`, that token is sent instead of the live one, and a rejected one
|
||||
* opens no sign-in.
|
||||
* opens no sign-in. `interactiveReauth: false` keeps a rejected live token from
|
||||
* opening one too.
|
||||
*/
|
||||
export async function request(puter, method, route, body, { authToken } = {}) {
|
||||
export async function request(
|
||||
puter,
|
||||
method,
|
||||
route,
|
||||
body,
|
||||
{ authToken, interactiveReauth = true } = {},
|
||||
) {
|
||||
const response = await fetchUrl(`${puter.APIOrigin}${route}`, {
|
||||
method,
|
||||
...(authToken
|
||||
? { authToken, interactiveReauth: false }
|
||||
: { includePuterAuth: true }),
|
||||
...(interactiveReauth ? {} : { interactiveReauth: false }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,13 @@ const RENEWABLE_SUBSCRIPTION_ERRORS = new Set([
|
||||
'push_subscription_invalid',
|
||||
'push_endpoint_revoked',
|
||||
]);
|
||||
/** What the server says about a subscription. */
|
||||
const SUBSCRIPTION_STATES = new Set([
|
||||
'active',
|
||||
'removed',
|
||||
'revoked',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
export const webPushSupported = (g = globalThis) =>
|
||||
typeof g.navigator?.serviceWorker?.register === 'function' &&
|
||||
@@ -259,58 +266,19 @@ const currentSubscription = async (puter, registration, key) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes this browser and registers it with the server. A new subscription
|
||||
* is confirmed by a push the worker hands back; a known one is only refreshed.
|
||||
* Needs notification permission already granted.
|
||||
*
|
||||
* @param {import('../../../index.js').Puter} puter
|
||||
* @param {{
|
||||
* worker: { url: string; scope?: string };
|
||||
* label?: string;
|
||||
* nav?: Navigator;
|
||||
* onPending?: () => void;
|
||||
* signal?: AbortSignal;
|
||||
* }} options
|
||||
* `onPending` runs once the server has the subscription and only its
|
||||
* confirmation is left. Aborting `signal` stops waiting for that
|
||||
* confirmation, with `push_verification_canceled`.
|
||||
* @returns {Promise<Record<string, unknown>>} The server's device record.
|
||||
* Announces `subscription` and, for one the server doesn't have yet, waits for
|
||||
* the confirmation push and verifies it.
|
||||
*/
|
||||
export async function syncWebPush(
|
||||
const register = async (
|
||||
puter,
|
||||
{ worker, label, nav = globalThis.navigator, onPending, signal },
|
||||
) {
|
||||
const { publicKey } = await request(puter, 'GET', '/push/web/key');
|
||||
if (typeof publicKey !== 'string' || publicKey === '') {
|
||||
throw new PuterJSError(
|
||||
"Push notifications aren't available on this deployment",
|
||||
'push_unavailable',
|
||||
);
|
||||
}
|
||||
const key = fromBase64Url(publicKey);
|
||||
const registration = await registerWorker(nav, worker, {
|
||||
keepSiteWorker: puter.env !== 'gui',
|
||||
});
|
||||
|
||||
let subscription =
|
||||
(await currentSubscription(puter, registration, key)) ??
|
||||
(await subscribeWith(registration, key));
|
||||
await rememberPuterKey(publicKey);
|
||||
|
||||
nav,
|
||||
subscription,
|
||||
{ label, onPending, signal },
|
||||
) => {
|
||||
// Listening first: the confirmation can arrive before the POST returns.
|
||||
const challenge = nextChallenge(nav, signal);
|
||||
try {
|
||||
let result;
|
||||
try {
|
||||
result = await announce(puter, subscription, label);
|
||||
} catch (error) {
|
||||
if (!RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code)) throw error;
|
||||
// A brand-new subscription the push service doesn't know yet, or an
|
||||
// endpoint another account turned off here.
|
||||
await subscription.unsubscribe();
|
||||
subscription = await subscribeWith(registration, key);
|
||||
result = await announce(puter, subscription, label);
|
||||
}
|
||||
const result = await announce(puter, subscription, label);
|
||||
if (result.status === 'active') return result.device;
|
||||
onPending?.();
|
||||
const answer = await challenge.promise;
|
||||
@@ -333,6 +301,92 @@ export async function syncWebPush(
|
||||
} finally {
|
||||
challenge.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes this browser and registers it with the server. A new subscription
|
||||
* is confirmed by a push the worker hands back; a known one is only refreshed.
|
||||
* Needs notification permission already granted.
|
||||
*
|
||||
* @param {import('../../../index.js').Puter} puter
|
||||
* @param {{
|
||||
* worker: { url: string; scope?: string };
|
||||
* label?: string;
|
||||
* nav?: Navigator;
|
||||
* onPending?: () => void;
|
||||
* signal?: AbortSignal;
|
||||
* renewRemoved?: boolean;
|
||||
* }} options
|
||||
* `onPending` runs once the server has the subscription and only its
|
||||
* confirmation is left. Aborting `signal` stops waiting for that
|
||||
* confirmation, with `push_verification_canceled`. `renewRemoved` replaces a
|
||||
* subscription the user removed in Puter instead of rejecting with
|
||||
* `push_device_removed`.
|
||||
* @returns {Promise<Record<string, unknown>>} The server's device record.
|
||||
*/
|
||||
export async function syncWebPush(
|
||||
puter,
|
||||
{
|
||||
worker,
|
||||
label,
|
||||
nav = globalThis.navigator,
|
||||
onPending,
|
||||
signal,
|
||||
renewRemoved = false,
|
||||
},
|
||||
) {
|
||||
const { publicKey } = await request(puter, 'GET', '/push/web/key');
|
||||
if (typeof publicKey !== 'string' || publicKey === '') {
|
||||
throw new PuterJSError(
|
||||
"Push notifications aren't available on this deployment",
|
||||
'push_unavailable',
|
||||
);
|
||||
}
|
||||
const key = fromBase64Url(publicKey);
|
||||
const registration = await registerWorker(nav, worker, {
|
||||
keepSiteWorker: puter.env !== 'gui',
|
||||
});
|
||||
|
||||
let subscription =
|
||||
(await currentSubscription(puter, registration, key)) ??
|
||||
(await subscribeWith(registration, key));
|
||||
await rememberPuterKey(publicKey);
|
||||
|
||||
const options = { label, onPending, signal };
|
||||
try {
|
||||
return await register(puter, nav, subscription, options);
|
||||
} catch (error) {
|
||||
const renewable =
|
||||
RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code) ||
|
||||
(renewRemoved && error?.code === 'push_device_removed');
|
||||
if (!renewable || signal?.aborted) throw error;
|
||||
// A brand-new subscription the push service doesn't know yet, an
|
||||
// endpoint another account turned off here, or one the user removed.
|
||||
await subscription.unsubscribe();
|
||||
subscription = await subscribeWith(registration, key);
|
||||
return await register(puter, nav, subscription, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's answer for this browser's subscription at `endpoint`, or null
|
||||
* when it can't tell, as on a server without the route. Never throws.
|
||||
*
|
||||
* @returns {Promise<'active' | 'removed' | 'revoked' | 'unknown' | null>}
|
||||
*/
|
||||
export async function webPushState(puter, endpoint) {
|
||||
try {
|
||||
const { state } = await request(
|
||||
puter,
|
||||
'POST',
|
||||
'/push/web/subscriptions/status',
|
||||
{ endpoint },
|
||||
{ interactiveReauth: false },
|
||||
);
|
||||
return SUBSCRIPTION_STATES.has(state) ? state : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock('../../lib/networkUtils.js', () => ({
|
||||
}));
|
||||
|
||||
const { NotificationsModule } = await import('./index.js');
|
||||
const { webPushState } = await import('./lib/webPush.js');
|
||||
const { PuterJSError } = await import('../../lib/PuterJSError.js');
|
||||
|
||||
const jsonResponse = (body, status = 200) => ({
|
||||
@@ -1336,3 +1337,44 @@ describe('devices', () => {
|
||||
expect(mockFetchUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webPushState', () => {
|
||||
const host = { APIOrigin: 'https://api.test' };
|
||||
|
||||
it('asks POST /push/web/subscriptions/status without opening a sign-in', async () => {
|
||||
mockFetchUrl.mockResolvedValue(jsonResponse({ state: 'removed' }));
|
||||
await expect(
|
||||
webPushState(host, 'https://push.example/1'),
|
||||
).resolves.toBe('removed');
|
||||
const [url, init] = mockFetchUrl.mock.calls[0];
|
||||
expect(url).toBe('https://api.test/push/web/subscriptions/status');
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
includePuterAuth: true,
|
||||
interactiveReauth: false,
|
||||
body: JSON.stringify({ endpoint: 'https://push.example/1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a server without the route',
|
||||
() =>
|
||||
jsonResponse({ message: 'Not Found', code: 'not_found' }, 404),
|
||||
],
|
||||
['an error', () => jsonResponse({ code: 'internal_error' }, 500)],
|
||||
['an unknown state', () => jsonResponse({ state: 'paused' })],
|
||||
])('is null for %s', async (_name, response) => {
|
||||
mockFetchUrl.mockResolvedValue(response());
|
||||
await expect(
|
||||
webPushState(host, 'https://push.example/1'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it('is null when the request fails', async () => {
|
||||
mockFetchUrl.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(
|
||||
webPushState(host, 'https://push.example/1'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,10 @@ const STATES = new Set(['on', 'off', 'blocked', 'unavailable']);
|
||||
* that the browser blocks notifications, and `unavailable` that this browser,
|
||||
* page or server can't receive pushes.
|
||||
*
|
||||
* On a website, `on` means the user turned pushes on here with `enable()` and
|
||||
* this browser holds the subscription. Inside the Puter desktop it means pushes
|
||||
* are on in this browser and the app holds `notifications:push`.
|
||||
* On a website, `on` means the user turned pushes on here with `enable()`, this
|
||||
* browser holds the subscription, and Puter hasn't had this browser removed or
|
||||
* the app turned off. Inside the Puter desktop it means pushes are on in this
|
||||
* browser and the app holds `notifications:push`.
|
||||
*
|
||||
* @this {import('./index.js').NotificationsModule}
|
||||
* @param {{ serviceWorker?: string }} [options]
|
||||
|
||||
@@ -473,6 +473,78 @@ describe('syncWebPush', () => {
|
||||
expect(mockRequest).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects push_device_removed and keeps the subscription unless told to renew it', async () => {
|
||||
const removed = makeSubscription();
|
||||
const nav = makeNav({ existing: removed });
|
||||
server(() => {
|
||||
throw new PuterJSError('removed', 'push_device_removed');
|
||||
});
|
||||
await expect(syncWebPush(puter, { worker, nav })).rejects.toMatchObject(
|
||||
{ code: 'push_device_removed' },
|
||||
);
|
||||
expect(removed.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces a subscription the user removed in Puter with renewRemoved, once', async () => {
|
||||
const removed = makeSubscription(undefined, 'https://push.example/1');
|
||||
const nav = makeNav({ existing: removed });
|
||||
nav.pushManager.subscribe.mockResolvedValue(
|
||||
makeSubscription(undefined, 'https://push.example/2'),
|
||||
);
|
||||
server((route, body) => {
|
||||
if (body.subscription.endpoint === 'https://push.example/1')
|
||||
throw new PuterJSError('removed', 'push_device_removed');
|
||||
if (route === '/push/web/subscriptions') {
|
||||
queueMicrotask(() =>
|
||||
nav.post({
|
||||
type: 'puter-push-challenge',
|
||||
challenge: 'c.sig',
|
||||
}),
|
||||
);
|
||||
return { status: 'pending' };
|
||||
}
|
||||
return { device: { uid: 'd6' } };
|
||||
});
|
||||
await expect(
|
||||
syncWebPush(puter, { worker, nav, renewRemoved: true }),
|
||||
).resolves.toEqual({ uid: 'd6' });
|
||||
expect(removed.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(nav.pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('replaces a subscription the confirmation finds removed, with renewRemoved', async () => {
|
||||
const first = makeSubscription(undefined, 'https://push.example/1');
|
||||
const second = makeSubscription(undefined, 'https://push.example/2');
|
||||
const nav = makeNav();
|
||||
nav.pushManager.subscribe
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(second);
|
||||
server((route, body) => {
|
||||
if (route === '/push/web/subscriptions') {
|
||||
queueMicrotask(() =>
|
||||
nav.post({
|
||||
type: 'puter-push-challenge',
|
||||
challenge: 'c.sig',
|
||||
}),
|
||||
);
|
||||
return { status: 'pending' };
|
||||
}
|
||||
if (body.subscription.endpoint === 'https://push.example/1')
|
||||
throw new PuterJSError('removed', 'push_device_removed');
|
||||
return { device: { uid: 'd7' } };
|
||||
});
|
||||
await expect(
|
||||
syncWebPush(puter, { worker, nav, renewRemoved: true }),
|
||||
).resolves.toEqual({ uid: 'd7' });
|
||||
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockRequest.mock.calls.filter(
|
||||
([, , route]) => route === '/push/web/subscriptions/verify',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeWebPush', () => {
|
||||
@@ -797,6 +869,39 @@ describe('onPush', () => {
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('ends a subscription the user removed in Puter, quietly, and keeps pushes off until enable()', async () => {
|
||||
store.set(OPT_IN, true);
|
||||
const removed = makeSubscription();
|
||||
const nav = makeNav({ existing: removed });
|
||||
withBrowser(nav);
|
||||
server(() => {
|
||||
throw new PuterJSError('removed', 'push_device_removed');
|
||||
});
|
||||
const error = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const onError = vi.fn();
|
||||
const { module } = makeModule();
|
||||
await module.onPush('chat', () => {}, { onError });
|
||||
await vi.waitFor(() => expect(store.has(OPT_IN)).toBe(false));
|
||||
expect(removed.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockRequest.mock.calls.map(([, method]) => method),
|
||||
).not.toContain('DELETE');
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
error.mockRestore();
|
||||
|
||||
nav.pushManager.getSubscription.mockResolvedValue(null);
|
||||
await expect(module.status()).resolves.toBe('off');
|
||||
mockRequest.mockClear();
|
||||
const { module: reloaded } = makeModule();
|
||||
await reloaded.onPush('chat', () => {});
|
||||
await flush();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports any other failure as a sync failure', async () => {
|
||||
store.set(OPT_IN, true);
|
||||
withBrowser(makeNav({ existing: makeSubscription() }));
|
||||
@@ -1307,6 +1412,27 @@ describe('enable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable after the user removed this browser in Puter', () => {
|
||||
it('replaces the removed subscription and turns pushes back on', async () => {
|
||||
const removed = makeSubscription(undefined, 'https://push.example/1');
|
||||
const nav = makeNav({ existing: removed });
|
||||
nav.pushManager.subscribe.mockResolvedValue(
|
||||
makeSubscription(undefined, 'https://push.example/2'),
|
||||
);
|
||||
withBrowser(nav);
|
||||
server((route, body) => {
|
||||
if (body.subscription.endpoint === 'https://push.example/1')
|
||||
throw new PuterJSError('removed', 'push_device_removed');
|
||||
return { status: 'pending' };
|
||||
});
|
||||
const { module } = makeModule();
|
||||
await expect(module.enable()).resolves.toBe(true);
|
||||
expect(removed.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(nav.pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(store.get(OPT_IN)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('is unavailable where the browser has no Web Push, and outside a page', async () => {
|
||||
const { module } = makeModule();
|
||||
@@ -1321,6 +1447,7 @@ describe('status', () => {
|
||||
withBrowser(makeNav({ existing: makeSubscription() }), 'denied');
|
||||
const { module } = makeModule();
|
||||
await expect(module.status()).resolves.toBe('blocked');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is off before the browser is asked, without a subscription, or without the user`s opt-in', async () => {
|
||||
@@ -1334,6 +1461,7 @@ describe('status', () => {
|
||||
store.delete(OPT_IN);
|
||||
withBrowser(makeNav({ existing: makeSubscription() }));
|
||||
await expect(makeModule().module.status()).resolves.toBe('off');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is on with a subscription the user turned on here, and off after disable()', async () => {
|
||||
@@ -1367,6 +1495,89 @@ describe('status', () => {
|
||||
const { module, host } = makeModule();
|
||||
host.authToken = null;
|
||||
await expect(module.status()).resolves.toBe('off');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('checks an on with Puter', () => {
|
||||
let subscription;
|
||||
const answer = (reply) =>
|
||||
mockRequest.mockImplementation(async (_p, _m, route) => {
|
||||
if (route !== '/push/web/subscriptions/status')
|
||||
throw new Error(`unexpected ${route}`);
|
||||
return await reply();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
store.set(OPT_IN, true);
|
||||
subscription = makeSubscription();
|
||||
withBrowser(makeNav({ existing: subscription }));
|
||||
});
|
||||
|
||||
it.each(['active', 'unknown'])(
|
||||
'is on when Puter says %s',
|
||||
async (state) => {
|
||||
answer(() => ({ state }));
|
||||
await expect(makeModule().module.status()).resolves.toBe('on');
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'POST',
|
||||
'/push/web/subscriptions/status',
|
||||
{ endpoint: subscription.endpoint },
|
||||
{ interactiveReauth: false },
|
||||
);
|
||||
expect(store.get(OPT_IN)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('is off once the user removed this browser, and ends the subscription here only', async () => {
|
||||
answer(() => ({ state: 'removed' }));
|
||||
await expect(makeModule().module.status()).resolves.toBe('off');
|
||||
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
expect(store.has(OPT_IN)).toBe(false);
|
||||
});
|
||||
|
||||
it('is off once the user turned the app off in Puter, keeping the subscription', async () => {
|
||||
answer(() => ({ state: 'revoked' }));
|
||||
await expect(makeModule().module.status()).resolves.toBe('off');
|
||||
expect(subscription.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(store.has(OPT_IN)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an older server without the route',
|
||||
() => {
|
||||
throw new PuterJSError('no', 'push_unavailable');
|
||||
},
|
||||
],
|
||||
[
|
||||
'a network failure',
|
||||
() => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
],
|
||||
['a malformed reply', () => ({ state: 'sideways' })],
|
||||
['an empty reply', () => ({})],
|
||||
])('stays on after %s', async (_name, reply) => {
|
||||
answer(reply);
|
||||
await expect(makeModule().module.status()).resolves.toBe('on');
|
||||
expect(subscription.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(store.get(OPT_IN)).toBe(true);
|
||||
});
|
||||
|
||||
it('asks in the Puter GUI too', async () => {
|
||||
store.delete(OPT_IN);
|
||||
const nav = makeNav({
|
||||
existing: subscription,
|
||||
active: { scriptURL: `${ORIGIN}/sw.js` },
|
||||
});
|
||||
withBrowser(nav);
|
||||
answer(() => ({ state: 'removed' }));
|
||||
const { module } = makeModule({ env: 'gui' });
|
||||
await expect(module.status()).resolves.toBe('off');
|
||||
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user