mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456d98dd13 | ||
|
|
221a5ea751 | ||
|
|
d36cbca317 | ||
|
|
37f8a7fc3c | ||
|
|
b850483133 | ||
|
|
bb67576ea3 | ||
|
|
58b4a6fb4d | ||
|
|
ed71e7b833 |
No files matched your search
+1
-1
@@ -69,7 +69,7 @@ interface PushClient {
|
||||
}
|
||||
```
|
||||
|
||||
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app may push to Puter's devices (`puterDevices` in `GET /push/apps`); the browser does not prompt then. That call passes `{ background: true }`: a browser the user removed in Puter stays removed, and the built-in client rejects with code `push_device_removed`, after which core records the browser as turned off. Before a background call the built-in client asks `POST /push/web/subscriptions/status` about every earlier endpoint it knows: the marker's `oldEndpoint` and the last endpoint the server answered `active` for (`puter.push.last-endpoint:<user uuid>` in `localStorage`). On a 429 it registers nothing and rejects with `too_many_requests`, which the sync reports as `later`, keeping the marker for the next run. When any answer is `removed`, it ends the current subscription in the browser, clears the marker and rejects with `push_device_removed`, so a subscription change or a dropped subscription never brings back a browser removed from another device. A click renews it as before. For a click the built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`); in the background it runs the same registration without replacing a removed subscription or opening Puter's sign-in. It then resolves the `uid` of this browser's `webpush` device from `listDevices()` and clears the subscription-changed marker. It rejects when permission is refused or the browser wasn't registered.
|
||||
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app may push to Puter's devices (`puterDevices` in `GET /push/apps`); the browser does not prompt then. That call passes `{ background: true }`: a browser the user removed in Puter stays removed, and the built-in client rejects with code `push_device_removed`, after which core records the browser as turned off. Before a background call the built-in client asks `POST /push/web/subscriptions/status` about every earlier endpoint it knows: the marker's `oldEndpoint` and the last endpoint the server answered `active` for (`puter.push.last-endpoint:<user uuid>` in `localStorage`). On a 429 it registers nothing and rejects with `too_many_requests`, which the sync reports as `later`, keeping the marker for the next run. When any answer is `removed`, it ends the current subscription in the browser, clears the marker and rejects with `push_device_removed`, so a subscription change or a dropped subscription never brings back a browser removed from another device. A click renews it as before. For a click the built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`); in the background it runs `puter.notifications.enable({ background: true })`, the same registration without a permission prompt, without replacing a removed subscription and without opening Puter's sign-in. It then resolves the `uid` of this browser's `webpush` device from `listDevices()` and clears the subscription-changed marker. It rejects when permission is refused or the browser wasn't registered.
|
||||
- `disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
|
||||
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user and `POST /push/web/subscriptions/status` answers `active` for the current subscription's endpoint, and `deviceUid` is that device's `uid`. A server that can't answer leaves the device match alone, and so does an `unknown` for an endpoint `enable()` registered in this page in the last 5 minutes, since that check can trail a registration. While the subscription-changed marker is set, `registered` is false. With `{ cached: true }` a client may answer with the last registered state it read in this page. Core passes it for the silent registration on app launch and for an app's `puter.notifications.status()`; the Notifications tab and the push dialogs read fresh. The built-in client keeps that state in memory for the same user and subscription for up to 5 minutes, from `enable()` or once the server answered `active` for the endpoint, and answers from it without either request. Reads of the same user and endpoint share one real check while it is in flight. It drops it on `disable()`, a failed `enable()`, an unregistered answer, a set subscription-changed marker, and the worker's `puter-push-subscription-changed` message, so a new page load always asks the server. Whether the calling app may push is checked on every call, against a `GET /push/apps` answer the page reuses for 30 seconds and drops on `enable()`, `disable()`, a grant, a revoke and a user change. When it can't be read, or leaves the app out, the grant alone counts.
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ On a website with nobody signed in, it opens the sign-in after the browser's pro
|
||||
|
||||
Inside the Puter desktop it does what [`enablePush()`](/Notifications/enablePush/) does, and it never rejects. When your app doesn't hold the permission yet, that's Puter's permission dialog, and allowing it also turns pushes on in this browser.
|
||||
|
||||
With `background: true` it runs without a click, for example on page load, and never opens the browser's prompt, Puter's dialog or the sign-in. On a website it turns pushes back on only for a signed-in user who turned them on in this browser with a click and hasn't turned them off since, whether with [`disable()`](/Notifications/disable/), by removing this browser in Puter, or by turning your app off in Puter. Otherwise, or when the browser hasn't already granted notification permission, it resolves `false` and sends no push registration. It also resolves `false` when pushes are turned off here or elsewhere while it runs. A browser the user removed in Puter stays removed. When the user removed this browser or turned your app off in Puter and this browser hasn't learned it yet, the call rejects once with `push_device_removed` or `push_revoked`, and later calls resolve `false`. Inside the Puter desktop it resolves whether pushes are already on, as [`status()`](/Notifications/status/) reports, without opening Puter's permission dialog.
|
||||
|
||||
## Syntax
|
||||
```js
|
||||
puter.notifications.enable()
|
||||
@@ -39,6 +41,7 @@ puter.notifications.enable(options)
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `serviceWorker` | `String` | The path of the worker that imports `push-sw.js`. Defaults to `/puter-sw.js`. Pass the same path to [`onPush()`](/Notifications/onPush/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Only used on a website. |
|
||||
| `background` | `Boolean` | Turn pushes back on without a click, only where the browser already granted permission and, on a website, the user turned them on here with a click and hasn't turned them off since. Opens no prompt, dialog or sign-in. Defaults to `false`. |
|
||||
|
||||
## Return value
|
||||
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications, canceled Puter's dialog, or the browser can't receive pushes, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
|
||||
@@ -51,8 +54,8 @@ The promise rejects with an error carrying `message` and `code`:
|
||||
| --- | --- |
|
||||
| `push_unavailable` | This server has no push notification service, as on most self-hosted servers. |
|
||||
| `push_disabled` | This server has push notifications turned off. |
|
||||
| `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_device_removed` | The user removed this browser in Puter, and the fresh subscription was also refused. |
|
||||
| `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. With `background: true`, later background calls resolve `false`. |
|
||||
| `push_device_removed` | The user removed this browser in Puter, and the fresh subscription was also refused. With `background: true`, the user removed this browser in Puter, and later background calls resolve `false`. |
|
||||
| `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`. A subscription Puter made earlier, even with a key Puter has since changed, is replaced instead. |
|
||||
@@ -90,3 +93,20 @@ The promise rejects with an error carrying `message` and `code`:
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Turn pushes back on at page load
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
// Resolves false without asking unless the user turned pushes on here with a click.
|
||||
const on = await puter.notifications.enable({ background: true }).catch(() => false);
|
||||
puter.print(on ? 'Notifications are on<br>' : 'Notifications are off<br>');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
@@ -70,6 +70,7 @@ const runSync = async ({ appUid } = {}, {
|
||||
return 'removed';
|
||||
}
|
||||
if ( e?.code === 'too_many_requests' ) return 'later';
|
||||
if ( e?.code === 'unavailable' ) return 'unavailable';
|
||||
console.debug('[push] enable failed', e);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
@@ -91,13 +91,13 @@ describe('syncBrowserPush with the built-in client', () => {
|
||||
it('does not subscribe again, and stays off, when the user removed this browser in Puter', async () => {
|
||||
const notifications = await builtIn();
|
||||
notifications.listDevices.mockResolvedValue([]);
|
||||
notifications._enableWebPush.mockRejectedValue(Object.assign(new Error('removed'), { code: 'push_device_removed' }));
|
||||
notifications.enable.mockRejectedValue(Object.assign(new Error('removed'), { code: 'push_device_removed' }));
|
||||
const deps = { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true };
|
||||
await expect(syncBrowserPush({}, deps)).resolves.toBe('removed');
|
||||
expect(notifications._enableWebPush).toHaveBeenCalledWith({ url: '/sw.js', scope: '/' }, { background: true });
|
||||
expect(notifications.enable).not.toHaveBeenCalled();
|
||||
expect(notifications.enable).toHaveBeenCalledWith({ background: true });
|
||||
expect(notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
await expect(syncBrowserPush({}, deps)).resolves.toBe('opted_out');
|
||||
expect(notifications._enableWebPush).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.enable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,12 @@ describe('syncBrowserPush', () => {
|
||||
expect(client.enable).toHaveBeenCalledWith({ background: true });
|
||||
});
|
||||
|
||||
it('reports unavailable when this browser can`t receive pushes', async () => {
|
||||
const client = off();
|
||||
client.enable.mockRejectedValue(Object.assign(new Error('unavailable'), { code: 'unavailable' }));
|
||||
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true })).resolves.toBe('unavailable');
|
||||
});
|
||||
|
||||
it('leaves a browser the user removed in Puter off, now and on later loads', async () => {
|
||||
const client = off();
|
||||
client.enable.mockRejectedValue(Object.assign(new Error('removed'), { code: 'push_device_removed' }));
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
// The page's side of `/sw.js`: routed taps, and updates once push is on.
|
||||
|
||||
import { SUBSCRIPTION_CHANGED_MESSAGE } from '../../../puter-js/src/modules/notifications/lib/pushStore.js';
|
||||
import {
|
||||
dispatchPushTap,
|
||||
normalizePushTap,
|
||||
@@ -33,7 +34,6 @@ import { webPushClient } from './webPushClient.js';
|
||||
export const WORKER_URL = '/sw.js';
|
||||
export const WORKER_CLICK_MESSAGE = 'puter-notification-click';
|
||||
export const WORKER_CLICK_ACK_MESSAGE = 'puter-notification-click-ack';
|
||||
export const WORKER_SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
|
||||
/**
|
||||
* @param {Object} [params] - `window.gui_params`
|
||||
@@ -74,7 +74,7 @@ export const startPushWorker = async ({
|
||||
registerPushClient(webPushClient());
|
||||
}
|
||||
nav.serviceWorker.addEventListener('message', (event) => {
|
||||
if ( event.data?.type === WORKER_SUBSCRIPTION_CHANGED_MESSAGE ) {
|
||||
if ( event.data?.type === SUBSCRIPTION_CHANGED_MESSAGE ) {
|
||||
void resync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SUBSCRIPTION_CHANGED_MESSAGE } from '../../../puter-js/src/modules/notifications/lib/pushStore.js';
|
||||
import { closeLocalBanner, pushWorkerEnabled } from './pushWorker.js';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -99,7 +100,7 @@ describe('startPushWorker', () => {
|
||||
await start(await load(), { nav, resync });
|
||||
nav.listeners.message({ data: { type: 'puter-notification-click', id: 'click-1', notification: { uid: UID } }, source: { postMessage: vi.fn() } });
|
||||
expect(resync).not.toHaveBeenCalled();
|
||||
nav.listeners.message({ data: { type: 'puter-push-subscription-changed' }, source: {} });
|
||||
nav.listeners.message({ data: { type: SUBSCRIPTION_CHANGED_MESSAGE }, source: {} });
|
||||
expect(resync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,53 +19,22 @@
|
||||
|
||||
// Puter's own push client: standard Web Push through `/sw.js` via `puter.notifications`, used when the server publishes `pushWebPushKey`.
|
||||
|
||||
import {
|
||||
HANDLERS_STORE,
|
||||
RECEIPT_PREFIX,
|
||||
SUBSCRIPTION_CHANGED_MESSAGE,
|
||||
SUBSCRIPTION_CHANGED_PREFIX,
|
||||
idbDelete,
|
||||
idbGet,
|
||||
} from '../../../puter-js/src/modules/notifications/lib/pushStore.js';
|
||||
import { forgetPushApps, webPushEndpointState } from './notificationApi.js';
|
||||
|
||||
/** The GUI's worker, as `puter.notifications` registers it. */
|
||||
const GUI_WORKER = { url: '/sw.js', scope: '/' };
|
||||
|
||||
/** What `/sw.js` posts when the browser replaced the subscription. */
|
||||
const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
|
||||
// Where `/sw.js` marks a subscription the browser replaced, as puter.js's pushStore.js does.
|
||||
const DB_NAME = 'puter-push';
|
||||
const HANDLERS = 'handlers';
|
||||
const CLICKS = 'clicks';
|
||||
export const SUBSCRIPTION_CHANGED_PREFIX = 'puter:subscription-changed:';
|
||||
export const RECEIPT_PREFIX = 'puter:receipt:';
|
||||
|
||||
/** The endpoint the server last confirmed here for each user, in `localStorage`. */
|
||||
export const LAST_ENDPOINT_PREFIX = 'puter.push.last-endpoint:';
|
||||
|
||||
const removedError = () => Object.assign(new Error('this browser was removed from push in Puter'), { code: 'push_device_removed' });
|
||||
const busyError = () => Object.assign(new Error('Puter asked to wait before registering this browser'), { code: 'too_many_requests' });
|
||||
|
||||
/** Runs one request on the handlers store. @param {IDBFactory} idb @param {IDBTransactionMode} mode @param {(store: IDBObjectStore) => IDBRequest} action */
|
||||
const handlersStore = (idb, mode, action) => new Promise((resolve, reject) => {
|
||||
const open = idb.open(DB_NAME, 1);
|
||||
open.onupgradeneeded = () => {
|
||||
open.result.createObjectStore(HANDLERS);
|
||||
open.result.createObjectStore(CLICKS);
|
||||
};
|
||||
open.onerror = () => reject(open.error);
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
try {
|
||||
const req = action(db.transaction(HANDLERS, mode).objectStore(HANDLERS));
|
||||
req.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(req.result);
|
||||
};
|
||||
req.onerror = () => {
|
||||
db.close();
|
||||
reject(req.error);
|
||||
};
|
||||
} catch ( e ) {
|
||||
db.close();
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
});
|
||||
const unavailableError = () => Object.assign(new Error('push notifications are unavailable in this browser'), { code: 'unavailable' });
|
||||
|
||||
/** How long a page answers `cached` reads from its last `on`. */
|
||||
export const ON_KEPT_MS = 5 * 60 * 1000;
|
||||
@@ -113,7 +82,7 @@ export const webPushClient = ({
|
||||
/** The worker's mark for a replaced subscription, else null. */
|
||||
const marker = async (scope) => {
|
||||
try {
|
||||
return (await handlersStore(idb, 'readonly', (s) => s.get(SUBSCRIPTION_CHANGED_PREFIX + scope))) ?? null;
|
||||
return (await idbGet(HANDLERS_STORE, SUBSCRIPTION_CHANGED_PREFIX + scope, idb)) ?? null;
|
||||
} catch ( e ) {
|
||||
return null;
|
||||
}
|
||||
@@ -137,7 +106,7 @@ export const webPushClient = ({
|
||||
const clearChanged = async () => {
|
||||
try {
|
||||
const scope = (await registration())?.scope;
|
||||
if ( scope ) await handlersStore(idb, 'readwrite', (s) => s.delete(SUBSCRIPTION_CHANGED_PREFIX + scope));
|
||||
if ( scope ) await idbDelete(HANDLERS_STORE, SUBSCRIPTION_CHANGED_PREFIX + scope, idb);
|
||||
} catch ( e ) {
|
||||
// The next load registers again.
|
||||
}
|
||||
@@ -155,7 +124,7 @@ export const webPushClient = ({
|
||||
// The next background run asks again.
|
||||
}
|
||||
try {
|
||||
if ( worker?.scope ) await handlersStore(idb, 'readwrite', (s) => s.delete(RECEIPT_PREFIX + worker.scope));
|
||||
if ( worker?.scope ) await idbDelete(HANDLERS_STORE, RECEIPT_PREFIX + worker.scope, idb);
|
||||
} catch ( e ) {
|
||||
// A receipt is only sent for its own endpoint.
|
||||
}
|
||||
@@ -172,12 +141,14 @@ export const webPushClient = ({
|
||||
if ( earlier === 'removed' ) throw removedError();
|
||||
// The mark stays, so the next run asks again.
|
||||
if ( earlier === 'busy' ) throw busyError();
|
||||
await notifications._enableWebPush(GUI_WORKER, { background: true });
|
||||
return true;
|
||||
return await notifications.enable({ background: true });
|
||||
})
|
||||
: notifications.enable();
|
||||
return turningOn.then(async (on) => {
|
||||
if ( ! on ) throw new Error(`notifications ${NotificationApi?.permission ?? 'unavailable'}`);
|
||||
if ( ! on ) {
|
||||
const permission = NotificationApi?.permission;
|
||||
throw permission && permission !== 'granted' ? new Error(`notifications ${permission}`) : unavailableError();
|
||||
}
|
||||
const reading = generation;
|
||||
const user = userUid();
|
||||
const device = await currentDevice();
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
RECEIPT_PREFIX as STORED_RECEIPT_PREFIX,
|
||||
SUBSCRIPTION_CHANGED_PREFIX as STORED_CHANGED_PREFIX,
|
||||
RECEIPT_PREFIX,
|
||||
SUBSCRIPTION_CHANGED_PREFIX,
|
||||
} from '../../../puter-js/src/modules/notifications/lib/pushStore.js';
|
||||
import { cachedPushApps } from './notificationApi.js';
|
||||
import { registerPushClient } from './pushClient.js';
|
||||
import { syncBrowserPush } from './pushSync.js';
|
||||
import { LAST_ENDPOINT_PREFIX, ON_KEPT_MS, RECEIPT_PREFIX, SUBSCRIPTION_CHANGED_PREFIX, webPushClient } from './webPushClient.js';
|
||||
import { LAST_ENDPOINT_PREFIX, ON_KEPT_MS, webPushClient } from './webPushClient.js';
|
||||
|
||||
const SCOPE = 'https://puter.test/';
|
||||
const LAST_ENDPOINT_KEY = `${LAST_ENDPOINT_PREFIX}u-1`;
|
||||
const CHANGED = `${STORED_CHANGED_PREFIX}${SCOPE}`;
|
||||
const CHANGED = `${SUBSCRIPTION_CHANGED_PREFIX}${SCOPE}`;
|
||||
|
||||
/** `localStorage` over a Map. */
|
||||
const fakeStorage = (entries = {}) => {
|
||||
@@ -129,7 +129,8 @@ describe('webPushClient', () => {
|
||||
registerPushClient(client);
|
||||
const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||
await expect(syncBrowserPush({}, { NotificationApi: { permission: 'granted' }, userUid: 'u-1', storage, holdsPush: async () => true })).resolves.toBe('on');
|
||||
expect(notifications._enableWebPush).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.enable).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.enable).toHaveBeenCalledWith({ background: true });
|
||||
});
|
||||
|
||||
it('turns off through puter.notifications', async () => {
|
||||
@@ -145,16 +146,30 @@ describe('webPushClient', () => {
|
||||
expect(notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns on in the background without replacing a browser removed in Puter', async () => {
|
||||
it('turns on in the background through enable({ background: true }), not the internal method', async () => {
|
||||
const { notifications, client } = make({ devices: [current] });
|
||||
await expect(client.enable({ background: true })).resolves.toEqual({ deviceUid: 'd1' });
|
||||
expect(notifications._enableWebPush).toHaveBeenCalledWith({ url: '/sw.js', scope: '/' }, { background: true });
|
||||
expect(notifications.enable).not.toHaveBeenCalled();
|
||||
expect(notifications.enable).toHaveBeenCalledWith({ background: true });
|
||||
expect(notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
|
||||
const removed = Object.assign(new Error('removed'), { code: 'push_device_removed' });
|
||||
notifications._enableWebPush.mockRejectedValue(removed);
|
||||
notifications.enable.mockRejectedValue(removed);
|
||||
await expect(client.enable({ background: true })).rejects.toBe(removed);
|
||||
});
|
||||
|
||||
it('rejects in the background when enable() resolves false', async () => {
|
||||
const { notifications, client } = make({ devices: [current], on: false, permission: 'default' });
|
||||
await expect(client.enable({ background: true })).rejects.toThrow('notifications default');
|
||||
expect(notifications.listDevices).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects with unavailable, not the permission, when push isn`t available though permission is granted', async () => {
|
||||
const { notifications, client } = make({ devices: [current], on: false, permission: 'granted' });
|
||||
const failure = await client.enable({ background: true }).catch((e) => e);
|
||||
expect(failure).toMatchObject({ code: 'unavailable' });
|
||||
expect(failure.message).not.toContain('notifications granted');
|
||||
expect(notifications.listDevices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('webPushClient status with cached', () => {
|
||||
@@ -317,7 +332,7 @@ describe('webPushClient status with cached', () => {
|
||||
it('keeps no on read while an enable() that failed was running', async () => {
|
||||
const { notifications, client } = make({ devices: [current] });
|
||||
let fail;
|
||||
notifications._enableWebPush.mockImplementationOnce(() => new Promise((_, reject) => {
|
||||
notifications.enable.mockImplementationOnce(() => new Promise((_, reject) => {
|
||||
fail = reject;
|
||||
}));
|
||||
const enabling = client.enable({ background: true });
|
||||
@@ -356,7 +371,7 @@ describe('webPushClient and a browser removed from another device', () => {
|
||||
const made = removedElsewhere({ idb: fakeIdb({ [CHANGED]: { at: 1, oldEndpoint: OLD } }) });
|
||||
await expect(sync(made.storage)).resolves.toBe('removed');
|
||||
expect(made.endpointState).toHaveBeenCalledWith(OLD);
|
||||
expect(made.notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
expect(made.notifications.enable).not.toHaveBeenCalled();
|
||||
expect(NEW.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(made.idb.map.has(CHANGED)).toBe(false);
|
||||
expect(made.storage.map.get(OPT_OUT)).toBe('1');
|
||||
@@ -364,7 +379,7 @@ describe('webPushClient and a browser removed from another device', () => {
|
||||
});
|
||||
|
||||
it('forgets the removed endpoint`s receipt when it stays off', async () => {
|
||||
const RECEIPT = `${STORED_RECEIPT_PREFIX}${SCOPE}`;
|
||||
const RECEIPT = `${RECEIPT_PREFIX}${SCOPE}`;
|
||||
const made = removedElsewhere({ idb: fakeIdb({ [CHANGED]: { at: 1, oldEndpoint: OLD }, [RECEIPT]: { endpoint: OLD, receipt: 'r.sig' } }) });
|
||||
await expect(sync(made.storage)).resolves.toBe('removed');
|
||||
expect(made.idb.map.has(RECEIPT)).toBe(false);
|
||||
@@ -373,7 +388,7 @@ describe('webPushClient and a browser removed from another device', () => {
|
||||
it('stays off after the browser dropped the subscription, from the last endpoint the server confirmed', async () => {
|
||||
const made = removedElsewhere({ subscription: null, storage: fakeStorage({ [LAST_ENDPOINT_KEY]: OLD }) });
|
||||
await expect(sync(made.storage)).resolves.toBe('removed');
|
||||
expect(made.notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
expect(made.notifications.enable).not.toHaveBeenCalled();
|
||||
expect(made.storage.map.get(OPT_OUT)).toBe('1');
|
||||
});
|
||||
|
||||
@@ -401,7 +416,7 @@ describe('webPushClient and a browser removed from another device', () => {
|
||||
await expect(sync(made.storage)).resolves.toBe('removed');
|
||||
expect(made.endpointState).toHaveBeenCalledWith('https://push.test/0b');
|
||||
expect(made.endpointState).toHaveBeenCalledWith(OLD);
|
||||
expect(made.notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
expect(made.notifications.enable).not.toHaveBeenCalled();
|
||||
expect(made.storage.map.get(OPT_OUT)).toBe('1');
|
||||
});
|
||||
|
||||
@@ -409,14 +424,14 @@ describe('webPushClient and a browser removed from another device', () => {
|
||||
const made = removedElsewhere({ idb: fakeIdb({ [CHANGED]: { at: 1, oldEndpoint: OLD } }) });
|
||||
made.endpointState.mockResolvedValue('busy');
|
||||
await expect(sync(made.storage)).resolves.toBe('later');
|
||||
expect(made.notifications._enableWebPush).not.toHaveBeenCalled();
|
||||
expect(made.notifications.enable).not.toHaveBeenCalled();
|
||||
expect(made.idb.map.has(CHANGED)).toBe(true);
|
||||
expect(made.storage.map.has(OPT_OUT)).toBe(false);
|
||||
|
||||
made.endpointState.mockResolvedValue('unknown');
|
||||
made.notifications.listDevices.mockResolvedValue([current]);
|
||||
await expect(sync(made.storage)).resolves.toBe('on');
|
||||
expect(made.notifications._enableWebPush).toHaveBeenCalledTimes(1);
|
||||
expect(made.notifications.enable).toHaveBeenCalledWith({ background: true });
|
||||
});
|
||||
|
||||
it('turns on from a click while the server answers 429', async () => {
|
||||
@@ -431,7 +446,7 @@ describe('webPushClient and a browser removed from another device', () => {
|
||||
made.endpointState.mockResolvedValue('unknown');
|
||||
made.notifications.listDevices.mockResolvedValue([current]);
|
||||
await expect(sync(made.storage)).resolves.toBe('on');
|
||||
expect(made.notifications._enableWebPush).toHaveBeenCalledTimes(1);
|
||||
expect(made.notifications.enable).toHaveBeenCalledWith({ background: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,10 +474,5 @@ describe('webPushClient requests', () => {
|
||||
await cachedPushApps();
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reads the marker under the key puter.js and the worker use', () => {
|
||||
expect(SUBSCRIPTION_CHANGED_PREFIX).toBe(STORED_CHANGED_PREFIX);
|
||||
expect(RECEIPT_PREFIX).toBe(STORED_RECEIPT_PREFIX);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
RECEIPT_PREFIX,
|
||||
SERVER_KEY_ENTRY,
|
||||
SERVER_KEYS_ENTRY,
|
||||
SUBSCRIPTION_CHANGED_MESSAGE,
|
||||
SUBSCRIPTION_CHANGED_PREFIX,
|
||||
} from '../../puter-js/src/modules/notifications/lib/pushStore.js';
|
||||
|
||||
@@ -375,6 +376,7 @@ describe('service worker pushsubscriptionchange', () => {
|
||||
SERVER_KEYS: SERVER_KEYS_ENTRY,
|
||||
CHANGED_PREFIX: SUBSCRIPTION_CHANGED_PREFIX,
|
||||
RECEIPT_PREFIX,
|
||||
SUBSCRIPTION_CHANGED_MESSAGE,
|
||||
}) ) {
|
||||
expect(SOURCE).toContain(`const ${name} = '${value}';`);
|
||||
}
|
||||
@@ -387,7 +389,7 @@ describe('service worker pushsubscriptionchange', () => {
|
||||
expect(worker.idb.map.get(CHANGED)).toEqual({ at: expect.any(Number), oldEndpoint: 'https://push.example/1' });
|
||||
expect(worker.idb.map.has(RECEIPT)).toBe(false);
|
||||
expect(worker.scope.registration.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' });
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: SUBSCRIPTION_CHANGED_MESSAGE });
|
||||
});
|
||||
|
||||
it('marks the endpoint of the subscription the browser ended, over the receipt`s', async () => {
|
||||
@@ -444,7 +446,7 @@ describe('service worker pushsubscriptionchange', () => {
|
||||
const worker = loadWorker({ windows: [win], handlers: pushOn });
|
||||
worker.idb.broken = true;
|
||||
await worker.fire('pushsubscriptionchange', {});
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' });
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: SUBSCRIPTION_CHANGED_MESSAGE });
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
@@ -456,7 +458,7 @@ describe('service worker pushsubscriptionchange', () => {
|
||||
await worker.fire('pushsubscriptionchange', {});
|
||||
expect(worker.scope.clients.matchAll).toHaveBeenCalledWith({ type: 'window', includeUncontrolled: true });
|
||||
for ( const win of [desktop, hidden] ) {
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'puter-push-subscription-changed' });
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: SUBSCRIPTION_CHANGED_MESSAGE });
|
||||
}
|
||||
expect(other.postMessage).not.toHaveBeenCalled();
|
||||
expect(worker.shown).toEqual([]);
|
||||
|
||||
Vendored
+1
@@ -254,6 +254,7 @@ export type { PSocket, PTLSSocket } from './types/modules/networking/PSocket.js'
|
||||
export type {
|
||||
AppNotification,
|
||||
DelayedNotificationOptions,
|
||||
EnableOptions,
|
||||
NotificationAction,
|
||||
NotificationClick,
|
||||
NotificationDismissal,
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('./lib/pushStore.js', () => ({
|
||||
SERVER_KEYS_ENTRY: 'puter:server-keys',
|
||||
SYNCED_PREFIX: 'puter:synced:',
|
||||
SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:',
|
||||
SUBSCRIPTION_CHANGED_MESSAGE: 'puter-push-subscription-changed',
|
||||
TAP_BASE_PREFIX: 'puter:tap-base:',
|
||||
CLICK_KEEP_MS: 5 * 60 * 1000,
|
||||
takeClick: async () => null,
|
||||
@@ -177,3 +178,66 @@ describe('enable() on a website without the click', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable({ background: true }) on a website', () => {
|
||||
const makeBackground = (over = {}) =>
|
||||
new NotificationsModule({
|
||||
env: 'web',
|
||||
authToken: 't',
|
||||
APIOrigin: 'https://api.test',
|
||||
whoami: { uuid: 'u-1' },
|
||||
onAuthStateChanged: () => () => {},
|
||||
ui: { authenticateWithPuter: vi.fn(async () => {}) },
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens no prompt or dialog and resolves false while permission is default', async () => {
|
||||
navigator.userActivation.isActive = true;
|
||||
const enabling = makeBackground().enable({ background: true });
|
||||
expect(dialog()).toBeNull();
|
||||
await expect(enabling).resolves.toBe(false);
|
||||
expect(Notification.requestPermission).not.toHaveBeenCalled();
|
||||
expect(dialog()).toBeNull();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens no sign-in when the token is rejected', async () => {
|
||||
Notification.permission = 'granted';
|
||||
store.set('opt-in:u-1', true);
|
||||
mockRequest.mockRejectedValue(
|
||||
Object.assign(new Error('token rejected'), {
|
||||
code: 'token_auth_failed',
|
||||
}),
|
||||
);
|
||||
const module = makeBackground();
|
||||
await expect(module.enable({ background: true })).rejects.toMatchObject(
|
||||
{
|
||||
code: 'token_auth_failed',
|
||||
},
|
||||
);
|
||||
expect(mockRequest).toHaveBeenCalled();
|
||||
for (const [, , , , options] of mockRequest.mock.calls)
|
||||
expect(options).toMatchObject({ interactiveReauth: false });
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(module.puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens no sign-in and resolves false with nobody signed in', async () => {
|
||||
Notification.permission = 'granted';
|
||||
const module = makeBackground({ authToken: null });
|
||||
await expect(module.enable({ background: true })).resolves.toBe(false);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(module.puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
|
||||
expect(dialog()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -75,18 +75,44 @@ const askBrowser = (env) => {
|
||||
* `requestPermission()` from a click undoes that. A browser the user removed in
|
||||
* Puter is subscribed again.
|
||||
*
|
||||
* With `background`, it runs without a click and opens no prompt, dialog or
|
||||
* sign-in. It resolves `false` unless the browser already granted permission
|
||||
* and, on a website, the signed-in user turned pushes on here with a click and
|
||||
* hasn't turned them off since (`disable()`, or Puter removing this browser or
|
||||
* turning the app off), and sends no push registration then. It also resolves
|
||||
* `false` when pushes are turned off here or elsewhere while it runs. A removal
|
||||
* or turn-off this browser hasn't learned yet rejects once with
|
||||
* `push_device_removed` or `push_revoked`, and later calls resolve `false`.
|
||||
* Inside the Puter desktop it resolves whether pushes are already on, as
|
||||
* `status()` reports.
|
||||
*
|
||||
* @this {import('./index.js').NotificationsModule}
|
||||
* @param {{ serviceWorker?: string }} [options]
|
||||
* @param {import('./types.js').EnableOptions} [options]
|
||||
* @returns {Promise<boolean>} Whether this browser now receives pushes.
|
||||
*/
|
||||
export async function enable(options = {}) {
|
||||
const { puter } = this;
|
||||
if (puter.env === 'app') return this.enablePush();
|
||||
const background = options.background === true;
|
||||
if (puter.env === 'app')
|
||||
return background ? (await this.status()) === 'on' : this.enablePush();
|
||||
if (puter.env !== 'web' && puter.env !== 'gui') return false;
|
||||
if (!webPushSupported()) return false;
|
||||
const worker = workerFor(puter.env, options.serviceWorker);
|
||||
|
||||
if (background) {
|
||||
if (Notification.permission !== 'granted' || needsSignIn(puter))
|
||||
return false;
|
||||
try {
|
||||
return await this._enableWebPush(worker, { background: true });
|
||||
} catch (error) {
|
||||
// Turned off here or elsewhere while it ran, as if a moment earlier.
|
||||
if (error?.code === 'push_verification_canceled') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if ((await askBrowser(puter.env)) !== 'granted') return false;
|
||||
|
||||
if (needsSignIn(puter)) await signInVisitor(puter);
|
||||
await this._enableWebPush(workerFor(puter.env, options.serviceWorker));
|
||||
return true;
|
||||
return await this._enableWebPush(worker);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { request } from './lib/api.js';
|
||||
import {
|
||||
CLICK_KEEP_MS,
|
||||
HANDLERS_STORE,
|
||||
SUBSCRIPTION_CHANGED_MESSAGE,
|
||||
SUBSCRIPTION_CHANGED_PREFIX,
|
||||
SYNCED_PREFIX,
|
||||
TAP_BASE_PREFIX,
|
||||
@@ -53,8 +54,7 @@ export const CLICK_BROADCAST = 'notificationClick';
|
||||
export const WORKER_CLICK_MESSAGE = 'puter-notification-click';
|
||||
/** What the page answers a tap with when a click handler took it. */
|
||||
export const WORKER_CLICK_ACK_MESSAGE = 'puter-notification-click-ack';
|
||||
/** What a worker posts when the browser replaced the push subscription. */
|
||||
export const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
export { SUBSCRIPTION_CHANGED_MESSAGE };
|
||||
/** Query parameter `push-sw.js` adds to a page it opens for a tap. */
|
||||
export const CLICK_PARAM = 'puter.notification';
|
||||
|
||||
@@ -192,6 +192,7 @@ const tapBaseFor = (worker, pageUrl) => {
|
||||
*/
|
||||
export class NotificationsModule extends EventListener {
|
||||
// Rebound in the constructor so destructured calls keep `this`.
|
||||
/** @type {OmitThisParameter<typeof notify>} */
|
||||
notify = notify;
|
||||
/** @type {OmitThisParameter<typeof cancel>} */
|
||||
cancel = cancel;
|
||||
@@ -199,15 +200,25 @@ export class NotificationsModule extends EventListener {
|
||||
markShown = markShown;
|
||||
/** @type {OmitThisParameter<typeof dismiss>} */
|
||||
dismiss = dismiss;
|
||||
/** @type {OmitThisParameter<typeof onNotification>} */
|
||||
onNotification = onNotification;
|
||||
/** @type {OmitThisParameter<typeof enable>} */
|
||||
enable = enable;
|
||||
/** @type {OmitThisParameter<typeof disable>} */
|
||||
disable = disable;
|
||||
/** @type {OmitThisParameter<typeof status>} */
|
||||
status = status;
|
||||
/** @type {OmitThisParameter<typeof onPush>} */
|
||||
onPush = onPush;
|
||||
/** @type {OmitThisParameter<typeof requestPermission>} */
|
||||
requestPermission = requestPermission;
|
||||
/** @type {OmitThisParameter<typeof hasPermission>} */
|
||||
hasPermission = hasPermission;
|
||||
/** @type {OmitThisParameter<typeof enablePush>} */
|
||||
enablePush = enablePush;
|
||||
/** @type {OmitThisParameter<typeof listDevices>} */
|
||||
listDevices = listDevices;
|
||||
/** @type {OmitThisParameter<typeof removeDevice>} */
|
||||
removeDevice = removeDevice;
|
||||
|
||||
#pushListener = (notification) => {
|
||||
@@ -493,12 +504,19 @@ export class NotificationsModule extends EventListener {
|
||||
*
|
||||
* @param {{ url: string; scope?: string }} worker
|
||||
* @param {{ background?: boolean }} [options] `background` is a call without
|
||||
* a click: a browser the user removed in Puter rejects with
|
||||
* `push_device_removed`, and a rejected token opens no sign-in.
|
||||
* a click. A browser the user removed in Puter rejects with
|
||||
* `push_device_removed` and a rejected token opens no sign-in. On a website
|
||||
* it resolves false without the user's opt-in here, and rejects with
|
||||
* `push_verification_canceled` when the opt-in goes while it runs.
|
||||
* @returns {Promise<boolean>} Whether this browser was registered.
|
||||
* @internal
|
||||
*/
|
||||
async _enableWebPush(worker, { background = false } = {}) {
|
||||
await this.#userChange;
|
||||
const signal = this.#stopVerifying.signal;
|
||||
const needsOptIn = background && this.puter.env === 'web';
|
||||
if (needsOptIn && (!(await this.#optedIn()) || signal.aborted))
|
||||
return false;
|
||||
this.#useWorker(worker);
|
||||
this.#listenForOptInChanges();
|
||||
/** @type {() => void} */
|
||||
@@ -506,13 +524,13 @@ export class NotificationsModule extends EventListener {
|
||||
const sent = new Promise((resolve) => {
|
||||
pending = () => resolve(undefined);
|
||||
});
|
||||
const signal = this.#stopVerifying.signal;
|
||||
const syncing = syncWebPush(this.puter, {
|
||||
worker,
|
||||
onPending: pending,
|
||||
signal,
|
||||
renewRemoved: !background,
|
||||
interactive: !background,
|
||||
...(needsOptIn ? { stillWanted: () => this.#optedIn() } : {}),
|
||||
withLock: withPushLock,
|
||||
onAnnounced: () => this.#recordOptIn(true),
|
||||
onSynced: (endpoint) => this.#noteSynced(worker, endpoint),
|
||||
@@ -538,6 +556,7 @@ export class NotificationsModule extends EventListener {
|
||||
worker,
|
||||
'could not confirm this browser for pushes',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,6 +762,11 @@ export class NotificationsModule extends EventListener {
|
||||
return on;
|
||||
}
|
||||
|
||||
/** Whether the user's opt-in here is known to be on. */
|
||||
async #optedIn() {
|
||||
return (await this.#readOptIn()) === true;
|
||||
}
|
||||
|
||||
/** Stops other tabs' syncs once push is turned off here. */
|
||||
#optInChanged(kind) {
|
||||
this.#listenForOptInChanges();
|
||||
@@ -775,7 +799,7 @@ export class NotificationsModule extends EventListener {
|
||||
syncWebPush(this.puter, {
|
||||
worker,
|
||||
signal: this.#stopVerifying.signal,
|
||||
stillWanted: async () => (await this.#readOptIn()) === true,
|
||||
stillWanted: () => this.#optedIn(),
|
||||
withLock: withPushLock,
|
||||
onSynced: (endpoint) => this.#noteSynced(worker, endpoint),
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,8 @@ export const RECEIPT_PREFIX = 'puter:receipt:';
|
||||
export const TAP_BASE_PREFIX = 'puter:tap-base:';
|
||||
/** How long a kept tap waits for its page, as in `push-sw.js`. */
|
||||
export const CLICK_KEEP_MS = 5 * 60 * 1000;
|
||||
/** What a worker posts when the browser replaced the push subscription. */
|
||||
export const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed';
|
||||
|
||||
const open = (idb) =>
|
||||
new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1414,6 +1414,26 @@ describe('enablePush', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable({ background: true }) inside the desktop', () => {
|
||||
it.each([
|
||||
['on', true],
|
||||
['off', false],
|
||||
])(
|
||||
'opens no prompt and resolves whether pushes are already %s',
|
||||
async (state, on) => {
|
||||
const ui = {
|
||||
...makeUI(),
|
||||
enablePush: vi.fn(async () => true),
|
||||
pushStatus_: vi.fn(async () => state),
|
||||
};
|
||||
expect(await makeModule({ ui }).enable({ background: true })).toBe(
|
||||
on,
|
||||
);
|
||||
expect(ui.enablePush).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('disable inside the desktop', () => {
|
||||
it('has the desktop revoke the app`s push permission', async () => {
|
||||
const ui = { ...makeUI(), disablePush_: vi.fn(async () => true) };
|
||||
|
||||
@@ -186,6 +186,20 @@
|
||||
* (`push_verification_timeout`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for {@link import('./enable.js').enable}.
|
||||
*
|
||||
* @typedef {Object} EnableOptions
|
||||
* @property {string} [serviceWorker] Path of the app's worker that imports
|
||||
* `push-sw.js`. Default `/puter-sw.js`. Only used on a website.
|
||||
* @property {boolean} [background] Turn pushes on without a click, only where
|
||||
* the browser already granted permission and, on a website, the signed-in
|
||||
* user turned pushes on here with a click and hasn't turned them off since.
|
||||
* Opens no prompt, dialog or sign-in. Resolves false when pushes are turned
|
||||
* off here or elsewhere while it runs. A removal or turn-off in Puter rejects
|
||||
* once (`push_device_removed`, `push_revoked`), then resolves false.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether this app's pushes reach the browser the user is in. `off` means
|
||||
* `enable()` can turn them on, `blocked` that the browser blocks notifications,
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('./lib/pushStore.js', () => {
|
||||
SERVER_KEYS_ENTRY: 'puter:server-keys',
|
||||
SYNCED_PREFIX: 'puter:synced:',
|
||||
SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:',
|
||||
SUBSCRIPTION_CHANGED_MESSAGE: 'puter-push-subscription-changed',
|
||||
RECEIPT_PREFIX: 'puter:receipt:',
|
||||
TAP_BASE_PREFIX: 'puter:tap-base:',
|
||||
CLICK_KEEP_MS: 5 * 60 * 1000,
|
||||
@@ -2177,6 +2178,126 @@ describe('enable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable({ background: true })', () => {
|
||||
it('resolves false without a request where the browser can`t receive pushes', async () => {
|
||||
const nav = makeNav();
|
||||
withBrowser(nav);
|
||||
delete globalThis.PushManager;
|
||||
const { module } = makeModule();
|
||||
await expect(module.enable({ background: true })).resolves.toBe(false);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(nav.serviceWorker.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers again without a click for a user who turned pushes on here with a click', async () => {
|
||||
const nav = makeNav({ existing: makeSubscription() });
|
||||
withBrowser(nav);
|
||||
server();
|
||||
const { module } = makeModule();
|
||||
await expect(module.enable()).resolves.toBe(true);
|
||||
mockRequest.mockClear();
|
||||
await expect(module.enable({ background: true })).resolves.toBe(true);
|
||||
expect(Notification.requestPermission).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
mockRequest.mock.calls.map(([, method, route]) => [method, route]),
|
||||
).toContainEqual(['POST', '/push/web/subscriptions']);
|
||||
expect(store.get(OPT_IN)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves false without a request for a user who never turned pushes on here', async () => {
|
||||
const nav = makeNav();
|
||||
withBrowser(nav);
|
||||
server();
|
||||
const { module } = makeModule();
|
||||
await expect(module.enable({ background: true })).resolves.toBe(false);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays off when disable() runs while it reads the opt-in', async () => {
|
||||
store.set(OPT_IN, true);
|
||||
const nav = makeNav();
|
||||
withBrowser(nav);
|
||||
server();
|
||||
const { hasOptIn } = await import('./lib/pushStore.js');
|
||||
hasOptIn.mockClear();
|
||||
let answer = () => {};
|
||||
hasOptIn.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = () => resolve(true);
|
||||
}),
|
||||
);
|
||||
const { module } = makeModule();
|
||||
const enabling = module.enable({ background: true });
|
||||
await vi.waitFor(() => expect(hasOptIn).toHaveBeenCalled());
|
||||
await module.disable();
|
||||
answer();
|
||||
await expect(enabling).resolves.toBe(false);
|
||||
await flush();
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockRequest.mock.calls.filter(
|
||||
([, method, route]) =>
|
||||
method === 'POST' && route === '/push/web/subscriptions',
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(store.has(OPT_IN)).toBe(false);
|
||||
});
|
||||
|
||||
it('stops before subscribing when another tab turns pushes off meanwhile', async () => {
|
||||
store.set(OPT_IN, true);
|
||||
const nav = makeNav();
|
||||
withBrowser(nav);
|
||||
let keyAnswer = () => {};
|
||||
mockRequest.mockImplementation(async (_p, _method, route) => {
|
||||
if (route === '/push/web/key') {
|
||||
return await new Promise((resolve) => {
|
||||
keyAnswer = () => resolve({ publicKey: KEY });
|
||||
});
|
||||
}
|
||||
return { status: 'active', device: { uid: 'd1' } };
|
||||
});
|
||||
const { module } = makeModule();
|
||||
const enabling = module.enable({ background: true });
|
||||
await vi.waitFor(() =>
|
||||
expect(mockRequest.mock.calls.map(([, , route]) => route)).toContain(
|
||||
'/push/web/key',
|
||||
),
|
||||
);
|
||||
store.delete(OPT_IN);
|
||||
keyAnswer();
|
||||
await expect(enabling).resolves.toBe(false);
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(store.has(OPT_IN)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false without a request after disable()', async () => {
|
||||
const nav = makeNav({ existing: makeSubscription() });
|
||||
withBrowser(nav);
|
||||
server();
|
||||
const { module } = makeModule();
|
||||
await module.enable();
|
||||
await module.disable();
|
||||
nav.pushManager.getSubscription.mockResolvedValue(null);
|
||||
nav.pushManager.subscribe.mockClear();
|
||||
mockRequest.mockClear();
|
||||
await expect(module.enable({ background: true })).resolves.toBe(false);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false without a request outside a website or the Puter GUI', async () => {
|
||||
const nav = makeNav({ existing: makeSubscription() });
|
||||
withBrowser(nav);
|
||||
server();
|
||||
const { module } = makeModule({ env: 'web-worker' });
|
||||
await expect(module.enable({ background: true })).resolves.toBe(false);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(nav.serviceWorker.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable() while another tab loads', () => {
|
||||
it('records the opt-in before the other tab checks it, so the new subscription stays', async () => {
|
||||
store.set(SERVER_KEY, KEY);
|
||||
@@ -2242,6 +2363,35 @@ describe('enable after the user removed this browser in Puter', () => {
|
||||
expect(store.get(OPT_IN)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves it removed for a background enable(), registering nothing new', async () => {
|
||||
store.set(OPT_IN, true);
|
||||
const removed = makeSubscription(undefined, 'https://push.example/1');
|
||||
const nav = makeNav({ existing: removed });
|
||||
withBrowser(nav);
|
||||
server((route, body) => {
|
||||
if (body.subscription.endpoint === 'https://push.example/1')
|
||||
throw new PuterJSError('removed', 'push_device_removed');
|
||||
return { status: 'active', device: { uid: 'd2' } };
|
||||
});
|
||||
const { module } = makeModule();
|
||||
await expect(module.enable({ background: true })).rejects.toMatchObject(
|
||||
{ code: 'push_device_removed' },
|
||||
);
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockRequest.mock.calls
|
||||
.filter(([, , route]) => route === '/push/web/subscriptions')
|
||||
.map(([, , , body]) => body.subscription.endpoint),
|
||||
).toEqual(['https://push.example/1']);
|
||||
expect(store.has(OPT_IN)).toBe(false);
|
||||
|
||||
mockRequest.mockClear();
|
||||
nav.pushManager.getSubscription.mockResolvedValue(null);
|
||||
await expect(module.enable({ background: true })).resolves.toBe(false);
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('in the Puter GUI', () => {
|
||||
const RECEIPT = `handlers:puter:receipt:${ORIGIN}/`;
|
||||
/** A browser holding a removed subscription and its receipt. */
|
||||
@@ -2292,7 +2442,7 @@ describe('enable after the user removed this browser in Puter', () => {
|
||||
const { removed, nav } = removedHere();
|
||||
const { module } = makeModule({ env: 'gui' });
|
||||
await expect(
|
||||
module._enableWebPush(workerFor('gui'), { background: true }),
|
||||
module.enable({ background: true }),
|
||||
).rejects.toMatchObject({ code: 'push_device_removed' });
|
||||
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
|
||||
expect(removed.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
@@ -2305,9 +2455,7 @@ describe('enable after the user removed this browser in Puter', () => {
|
||||
it('opens no sign-in for a rejected token in the background', async () => {
|
||||
removedHere();
|
||||
const { module } = makeModule({ env: 'gui' });
|
||||
await module
|
||||
._enableWebPush(workerFor('gui'), { background: true })
|
||||
.catch(() => {});
|
||||
await module.enable({ background: true }).catch(() => {});
|
||||
expect(reauthOf('/push/web/key')).toEqual([false]);
|
||||
expect(reauthOf('/push/web/subscriptions')).toEqual([false]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { NotificationAction, Puter, PushShown } from '../../index.js';
|
||||
import type {
|
||||
EnableOptions,
|
||||
NotificationAction,
|
||||
NotificationListener,
|
||||
Puter,
|
||||
PushDevice,
|
||||
PushShown,
|
||||
PushStatus,
|
||||
} from '../../index.js';
|
||||
|
||||
export const shown: PushShown = { requireInteraction: true };
|
||||
// @ts-expect-error The display hint must be a boolean.
|
||||
@@ -38,3 +46,34 @@ export const listen = (puter: Puter): void => {
|
||||
// @ts-expect-error Unsupported event name.
|
||||
puter.notifications.on('shown', () => {});
|
||||
};
|
||||
|
||||
export const backgroundOptions: EnableOptions = { background: true, serviceWorker: '/puter-sw.js' };
|
||||
// @ts-expect-error `background` is a boolean.
|
||||
export const invalidBackground: EnableOptions = { background: 'yes' };
|
||||
|
||||
export const enableInBackground = async (puter: Puter): Promise<void> => {
|
||||
const on: boolean = await puter.notifications.enable({ background: true });
|
||||
const clicked: boolean = await puter.notifications.enable();
|
||||
// @ts-expect-error Unknown option.
|
||||
await puter.notifications.enable({ quiet: true });
|
||||
void [on, clicked];
|
||||
};
|
||||
|
||||
// Each method is called on `puter.notifications`, so a `this` type left on one fails here.
|
||||
export const callMethods = async (puter: Puter): Promise<void> => {
|
||||
const n = puter.notifications;
|
||||
const uid: string = await n.notify({ title: 'Hello' });
|
||||
await n.cancel('schedule-id');
|
||||
const marked: boolean = await n.markShown(uid);
|
||||
const dismissed: boolean = await n.dismiss(uid);
|
||||
const feed: NotificationListener = await n.onNotification(() => {});
|
||||
const disabled: boolean = await n.disable();
|
||||
const state: PushStatus = await n.status();
|
||||
const push: NotificationListener = await n.onPush('chat', () => {});
|
||||
const asked: boolean = await n.requestPermission();
|
||||
const held: boolean = await n.hasPermission();
|
||||
const offered: boolean = await n.enablePush();
|
||||
const devices: PushDevice[] = await n.listDevices();
|
||||
const removed: void = await n.removeDevice('device-uid');
|
||||
void [marked, dismissed, feed, disabled, state, push, asked, held, offered, devices, removed];
|
||||
};
|
||||
Reference in new issue
Block a user