diff --git a/doc/push-client.md b/doc/push-client.md index a6b1b7edf..2deae6ba6 100644 --- a/doc/push-client.md +++ b/doc/push-client.md @@ -10,7 +10,8 @@ Core does not subscribe the browser to a push service, and it has no `/push/*` r ```ts { uid?: string; title?: string; body?: string; icon?: string; group?: string; - target?: { app?: string; path?: string }; data?: unknown } + target?: { app?: string; path?: string }; data?: unknown; channel?: string; + type?: 'challenge'; challenge?: string } ``` `uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it. @@ -31,6 +32,17 @@ service_script(async (api) => { With no client registered, the tab says push notifications aren't available in this browser. +### Built-in Web Push client + +When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins: + +1. `GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration. +2. `POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`. +3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. Only then is the device registered, so a subscription proves the user allowed notifications in that browser. +4. `DELETE /push/web/subscriptions { endpoint }` removes it. + +Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only. + ## Interface ```ts diff --git a/src/backend/controllers/static/StaticAssetsController.test.ts b/src/backend/controllers/static/StaticAssetsController.test.ts index fd1a5dc11..5ac117b5b 100644 --- a/src/backend/controllers/static/StaticAssetsController.test.ts +++ b/src/backend/controllers/static/StaticAssetsController.test.ts @@ -151,6 +151,10 @@ describe('StaticAssetsController client_libs_root', () => { const sentPutility = await callGetHandler(router, '/putility/v1'); expect(sentPutility).toEqual({ filename: 'putility.js/v1.js', root }); + + expect(findRoute(router, 'get', '/v2/push-sw.js')?.options.subdomain).toBe('js'); + const sentWorker = await callGetHandler(router, '/v2/push-sw.js'); + expect(sentWorker).toEqual({ filename: 'puter.js/push-sw.js', root }); }); it('does not register the client_libs routes when the root is unset', () => { @@ -186,6 +190,9 @@ describe('StaticAssetsController puterjs_root', () => { expect(v1.root).toBe(root); const bareV1 = await callGetHandler(router, '/v1'); expect(bareV1.filename).toBe('puter.js'); + + const worker = await callGetHandler(router, '/v2/push-sw.js'); + expect(worker).toEqual({ filename: 'push-sw.js', root }); }); it('serves puter.dev.js when present (dev webpack build)', async () => { diff --git a/src/backend/controllers/static/StaticAssetsController.ts b/src/backend/controllers/static/StaticAssetsController.ts index 47b1065be..27d03dd02 100644 --- a/src/backend/controllers/static/StaticAssetsController.ts +++ b/src/backend/controllers/static/StaticAssetsController.ts @@ -26,9 +26,9 @@ import type { PuterRouter } from '../../core/http/PuterRouter'; /** * Static asset routes. * - * /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /putility/v1 → js - * subdomain /sdk/* → root subdomain — puter-js bundle /dist/_, /src/_, - * /assets/* → root subdomain + * /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /v2/push-sw.js, + * /putility/v1 → js subdomain /sdk/* → root subdomain — puter-js bundle + * /dist/_, /src/_, /assets/* → root subdomain * * Each block depends on its config root (`client_libs_root`, `gui_assets_root`, * `puterjs_root`). When unset, that block is skipped — deployments that don't @@ -55,6 +55,9 @@ export class StaticAssetsController extends PuterController { router.get('/putility/v1', { subdomain: 'js' }, (_req, res) => { res.sendFile('putility.js/v1.js', { root }); }); + router.get('/v2/push-sw.js', { subdomain: 'js' }, (_req, res) => { + res.sendFile('puter.js/push-sw.js', { root }); + }); } // puter-js SDK mount. GUI loads it at `/sdk/puter.dev.js`; the @@ -100,6 +103,13 @@ export class StaticAssetsController extends PuterController { router.get('/v2', { subdomain: 'js' }, (_req, res) => { res.sendFile(puterJsFile, { root: puterjsRoot }); }); + router.get( + '/v2/push-sw.js', + { subdomain: 'js' }, + (_req, res) => { + res.sendFile('push-sw.js', { root: puterjsRoot }); + }, + ); } } diff --git a/src/backend/services/events/EventForwardService.ts b/src/backend/services/events/EventForwardService.ts index 3ca50b16b..35ad0730e 100644 --- a/src/backend/services/events/EventForwardService.ts +++ b/src/backend/services/events/EventForwardService.ts @@ -408,7 +408,8 @@ export class EventForwardService extends PuterService { * peer region. Only this node's sockets count where presence is not kept. */ async isOnline(userId: number, appUid: string | null): Promise { - if (this.services.socket.has(forwardTarget(userId, appUid))) return true; + if (this.services.socket.has(forwardTarget(userId, appUid))) + return true; if (!this.active) return false; if (await this.heldInRegion(userId, appUid)) return true; return (await this.regionsFor(userId, appUid)).length > 0; diff --git a/src/docs/src/Notifications.md b/src/docs/src/Notifications.md index 8e4076f40..ae7032e9a 100644 --- a/src/docs/src/Notifications.md +++ b/src/docs/src/Notifications.md @@ -16,13 +16,12 @@ await puter.notifications.notify({ ## Where a notification shows -A notification always shows in the user's Puter notifications panel. While your app is open, it also reaches the app through [`onNotification()`](/Notifications/onNotification/). +A notification always shows in the user's Puter notifications panel. Puter then routes it one of two ways: -It also goes out as a push notification when all of these are true: +- **Live**, while your app is open and connected: it reaches the app through [`onNotification()`](/Notifications/onNotification/) and [`onPush()`](/Notifications/onPush/). +- **As a push**, while it isn't: to the browsers where the user turned pushes on for your app. -- The user has neither Puter nor your app open. -- The user allowed your app to send pushes. Ask with [`requestPermission()`](/Notifications/requestPermission/). -- The user has turned on push notifications on at least one device. Allowing your app in the permission prompt turns them on in that browser too, and [`enablePush()`](/Notifications/enablePush/) offers to turn them on in the browser your app is in. +For a push, the user has to have turned them on. On a website, call [`enable()`](/Notifications/enable/) from a click and handle pushes with [`onPush()`](/Notifications/onPush/); they arrive on your own origin through a small service worker. Inside the Puter desktop, pushes go through Puter: ask with [`requestPermission()`](/Notifications/requestPermission/), and [`enablePush()`](/Notifications/enablePush/) offers to turn them on in the browser. A user gets at most one push a minute from each app. A notification over that still shows in the panel; it's just not pushed. diff --git a/src/docs/src/Notifications/disable.md b/src/docs/src/Notifications/disable.md new file mode 100644 index 000000000..081f05413 --- /dev/null +++ b/src/docs/src/Notifications/disable.md @@ -0,0 +1,21 @@ +--- +title: puter.notifications.disable() +description: Turn off push notifications for your app in this browser. +platforms: [websites] +--- + +Ends this browser's push subscription for your app and removes it from Puter. Notifications still reach your app live while it's open. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt. + +## Syntax +```js +puter.notifications.disable() +puter.notifications.disable(options) +``` + +## Parameters + +#### `options` (Object) (optional) +- `serviceWorker` (String): path of the worker passed to `enable()`. Default `/puter-sw.js`. + +## Return value +A `Promise` that resolves to `true` when a subscription was removed, and `false` when there was none. diff --git a/src/docs/src/Notifications/enable.md b/src/docs/src/Notifications/enable.md new file mode 100644 index 000000000..e0a521d59 --- /dev/null +++ b/src/docs/src/Notifications/enable.md @@ -0,0 +1,29 @@ +--- +title: puter.notifications.enable() +description: Turn on push notifications for your app in this browser. +platforms: [websites, apps] +--- + +Turns on push notifications for your app in the browser the user is in. Call it from a click: it asks the browser's permission first, before anything else, because browsers only show that prompt from a user gesture. + +On a website, the browser asks on your own origin, and pushes arrive through your service worker (see [`onPush()`](/Notifications/onPush/)). Puter then sends a confirmation push to that browser and only keeps the subscription once your page hands it back, so a subscription can only come from a browser where the user said yes. Allowing it also lets your app's notifications go out as pushes, which the user can revoke in Puter's settings. + +Inside the Puter desktop it does what [`enablePush()`](/Notifications/enablePush/) does. + +Once enabled, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later visits without asking again. + +## Syntax +```js +puter.notifications.enable() +puter.notifications.enable(options) +``` + +## Parameters + +#### `options` (Object) (optional) +- `serviceWorker` (String): path of the worker that imports `push-sw.js`. Default `/puter-sw.js`. + +## 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 or the browser can't receive pushes. It rejects with `push_unavailable` when the server has no push service, `push_worker_unavailable` when the service worker can't be registered, and `push_verification_timeout` when the confirmation push didn't arrive. + +On a website with nobody signed in, it opens the sign-in after the browser's prompt. Signing the user in first avoids a blocked popup. diff --git a/src/docs/src/Notifications/onPush.md b/src/docs/src/Notifications/onPush.md new file mode 100644 index 000000000..c5451dfa6 --- /dev/null +++ b/src/docs/src/Notifications/onPush.md @@ -0,0 +1,85 @@ +--- +title: puter.notifications.onPush() +description: Handle your app's notifications for one key, live while the app is open and as browser pushes while it isn't. +platforms: [websites, apps] +--- + +Handles the notifications your app sends with `channel: key` in [`notify()`](/Notifications/notify/). While your app is open and connected, the handler runs in the page as each one arrives. While it isn't, Puter sends a browser push instead, and the handler runs in your site's service worker to decide what the notification shows. + +Puter picks the route: a notification never goes both ways. The user turns pushes on once with [`enable()`](/Notifications/enable/); after that, `onPush()` keeps this browser's subscription current on every page load without asking again. Different keys share that one permission. + +On a website, pushes reach your own origin, so your site has to serve a service worker that loads Puter's push script. Put this in `/puter-sw.js` at the root of your site: + +```js +importScripts('https://js.puter.com/v2/push-sw.js'); +``` + +If your site already has a service worker, add that line to it instead and pass its path as `serviceWorker`. Inside the Puter desktop, pushes go through Puter itself and no worker is needed. + +## Syntax +```js +puter.notifications.onPush(key, handler) +puter.notifications.onPush(key, handler, options) +``` + +## Parameters + +#### `key` (String) (required) +The `channel` your notifications are sent with: 1 to 32 lowercase letters, digits or `-`. + +#### `handler` (Function) (required) +Called with the notification (the same shape [`onNotification()`](/Notifications/onNotification/) delivers). It may return, or resolve to, an object that changes what the browser shows: + +- `title`, `body`, `icon`, `badge`, `image`, `tag`: strings, as in the browser's `showNotification()`. +- `url`: a path or URL on your origin, opened when the notification is tapped. Defaults to the notification's `target`. Other origins are replaced with your site's root. + +Returning nothing shows the notification's own `title` and `text`. In the page, the result is only shown when the tab is hidden. + +#### `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, the live listener stops, or keeping the subscription current fails. Without it, errors go to the console. + +## Handler limits + +On a website, the handler is saved as source and run again in your service worker when a push arrives with no page open. There it follows the Web Push rules: + +- **Self-contained.** It is re-created from its source, so it can't use variables, functions or imports from around it. Everything it needs must come from its argument or be written inside it. +- **No page.** It runs in a service worker: no `window`, `document` or `localStorage`. `fetch`, `indexedDB` and `caches` are available. +- **Fast.** It has 5 seconds. After that, or if it throws, the notification shows with its own title and text. +- **Something always shows.** Browsers require every push to show a notification, so a handler can change what shows but can't hide it. +- **Small.** A push carries about 4 KB, and `data` at most 1 KB. Send IDs and fetch the rest if you need it. +- **`eval`.** It is re-created with `new Function`, so if your worker is served with a Content-Security-Policy it must allow `'unsafe-eval'`. + +A handler that needs more, such as imported code, can be registered in the worker itself, where it wins over the saved one: + +```js +importScripts('https://js.puter.com/v2/push-sw.js', '/my-formatting.js'); +puterPush.onPush('chat', (n) => ({ title: formatTitle(n) })); +``` + +## Return value +A `Promise` that resolves to `{ off }`. `off()` stops the handler in this page and, when no other handler for `key` is left, removes the saved one. + +## Examples + +Chat messages + +```html + + + + + + + +``` diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 2d5115403..d25543474 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -550,6 +550,30 @@ let sidebar = [ source: '/Notifications/onNotification.md', path: '/Notifications/onNotification', }, + { + title: 'onPush()', + page_title: 'puter.notifications.onPush()', + title_tag: 'puter.notifications.onPush()', + icon: '/assets/img/function.svg', + source: '/Notifications/onPush.md', + path: '/Notifications/onPush', + }, + { + title: 'enable()', + page_title: 'puter.notifications.enable()', + title_tag: 'puter.notifications.enable()', + icon: '/assets/img/function.svg', + source: '/Notifications/enable.md', + path: '/Notifications/enable', + }, + { + title: 'disable()', + page_title: 'puter.notifications.disable()', + title_tag: 'puter.notifications.disable()', + icon: '/assets/img/function.svg', + source: '/Notifications/disable.md', + path: '/Notifications/disable', + }, { title: "on('click')", page_title: "puter.notifications.on('click')", diff --git a/src/gui/src/helpers/pushWorker.js b/src/gui/src/helpers/pushWorker.js index 73cb78d62..7ff22063f 100644 --- a/src/gui/src/helpers/pushWorker.js +++ b/src/gui/src/helpers/pushWorker.js @@ -28,6 +28,8 @@ import { PUSH_TAP_PARAM, } from './notificationTap.js'; import { clear_query_param } from './parseSharedPath.js'; +import { getPushClient, registerPushClient } from './pushClient.js'; +import { webPushClient } from './webPushClient.js'; export const WORKER_URL = '/sw.js'; export const WORKER_CLICK_MESSAGE = 'puter-notification-click'; @@ -151,6 +153,9 @@ export const startPushWorker = async ({ // Leave an existing worker: removing it ends this browser's subscription. if ( ! pushWorkerEnabled(params, nav) ) return; + if ( typeof params.pushWebPushKey === 'string' && params.pushWebPushKey && ! getPushClient() ) { + registerPushClient(webPushClient()); + } nav.serviceWorker.addEventListener('message', (event) => { if ( event.data?.type !== WORKER_CLICK_MESSAGE || ! normalizePushTap(event.data.notification) ) return; event.source?.postMessage?.({ type: WORKER_CLICK_ACK_MESSAGE, id: event.data.id }); diff --git a/src/gui/src/helpers/webPushClient.js b/src/gui/src/helpers/webPushClient.js new file mode 100644 index 000000000..f3654226d --- /dev/null +++ b/src/gui/src/helpers/webPushClient.js @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Puter's own push client: standard Web Push through `/sw.js`, via +// `puter.notifications`. Used when the server publishes `pushWebPushKey`. + +/** @returns {import('./pushClient.js').PushClient} */ +export const webPushClient = ({ + notifications = globalThis.puter?.notifications, + NotificationApi = globalThis.Notification, + nav = globalThis.navigator, +} = {}) => { + const currentDevice = async () => { + const devices = await notifications.listDevices(); + return devices.find((d) => d.current && d.platform === 'webpush') ?? null; + }; + return { + // Not async before `enable()`: it asks the browser inside the click. + enable: () => notifications.enable().then(async (on) => { + if ( ! on ) throw new Error(`notifications ${NotificationApi?.permission ?? 'unavailable'}`); + const device = await currentDevice(); + if ( ! device ) throw new Error('this browser was not registered'); + return { deviceUid: device.uid }; + }), + disable: async () => { + await notifications.disable(); + }, + status: async () => { + const permission = NotificationApi?.permission ?? 'default'; + const registration = await nav.serviceWorker?.getRegistration('/'); + const subscription = await registration?.pushManager?.getSubscription(); + if ( permission !== 'granted' || ! subscription ) return { permission, registered: false }; + const device = await currentDevice(); + return device + ? { permission, registered: true, deviceUid: device.uid } + : { permission, registered: false }; + }, + }; +}; diff --git a/src/gui/src/helpers/webPushClient.test.js b/src/gui/src/helpers/webPushClient.test.js new file mode 100644 index 000000000..6d15512b4 --- /dev/null +++ b/src/gui/src/helpers/webPushClient.test.js @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import { webPushClient } from './webPushClient.js'; + +const make = ({ on = true, devices = [], permission = 'granted', subscription = {} } = {}) => { + const notifications = { + enable: vi.fn(async () => on), + disable: vi.fn(async () => true), + listDevices: vi.fn(async () => devices), + }; + const nav = { + serviceWorker: { + getRegistration: vi.fn(async () => ({ pushManager: { getSubscription: async () => subscription } })), + }, + }; + return { notifications, client: webPushClient({ notifications, NotificationApi: { permission }, nav }) }; +}; + +const current = { uid: 'd1', platform: 'webpush', current: true }; + +describe('webPushClient', () => { + it('calls enable() before anything is awaited, and returns this browser`s device', async () => { + const { notifications, client } = make({ devices: [{ uid: 'x', platform: 'web', current: true }, current] }); + const pending = client.enable(); + expect(notifications.enable).toHaveBeenCalledTimes(1); + await expect(pending).resolves.toEqual({ deviceUid: 'd1' }); + }); + + it('rejects when the browser said no', async () => { + const { client } = make({ on: false, permission: 'denied' }); + await expect(client.enable()).rejects.toThrow('notifications denied'); + }); + + it('is registered only with a subscription the server lists for this session', async () => { + await expect(make({ devices: [current] }).client.status()).resolves.toEqual({ permission: 'granted', registered: true, deviceUid: 'd1' }); + await expect(make({ devices: [] }).client.status()).resolves.toEqual({ permission: 'granted', registered: false }); + await expect(make({ subscription: null, devices: [current] }).client.status()).resolves.toEqual({ permission: 'granted', registered: false }); + await expect(make({ permission: 'denied' }).client.status()).resolves.toEqual({ permission: 'denied', registered: false }); + }); + + it('turns off through puter.notifications', async () => { + const { notifications, client } = make(); + await client.disable(); + expect(notifications.disable).toHaveBeenCalled(); + }); +}); diff --git a/src/gui/src/sw.js b/src/gui/src/sw.js index f04cb8b13..e20dd223d 100644 --- a/src/gui/src/sw.js +++ b/src/gui/src/sw.js @@ -22,6 +22,9 @@ // is { uid, title, body, icon, group, target: { app, path }, data }. // On `pushsubscriptionchange` it posts { type: 'puter-push-subscription-changed' } // to this origin's windows, so a signed-in page can register again. +// A `type: 'challenge'` message is shown, then its `challenge` is posted to +// this origin's windows as { type: 'puter-push-challenge', challenge } for the +// page to confirm the subscription with the server. const DEFAULT_TITLE = 'New notification'; const DEFAULT_ICON = '/favicons/android-icon-192x192.png'; @@ -29,6 +32,7 @@ const SHOW_MESSAGE = 'puter-show-notification'; const CLICK_MESSAGE = 'puter-notification-click'; const CLICK_ACK_MESSAGE = 'puter-notification-click-ack'; const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed'; +const CHALLENGE_MESSAGE = 'puter-push-challenge'; const CLICK_PARAM = 'puter.notification'; // Browsers allow openWindow only shortly after the click. const CLICK_ACK_BUDGET_MS = 600; @@ -192,11 +196,22 @@ async function showPageMessage (scope, message) { await showMessage(scope.registration, message, scope.location.origin); } -async function announceSubscriptionChange (scope) { +async function postToWindows (scope, message) { const origin = scope.location.origin; const windowClients = await scope.clients.matchAll({ type: 'window', includeUncontrolled: true }); for ( const client of windowClients ) { - if ( sameOriginUrl(client.url, origin) ) client.postMessage({ type: SUBSCRIPTION_CHANGED_MESSAGE }); + if ( sameOriginUrl(client.url, origin) ) client.postMessage(message); + } +} + +function announceSubscriptionChange (scope) { + return postToWindows(scope, { type: SUBSCRIPTION_CHANGED_MESSAGE }); +} + +async function handlePush (scope, message) { + await showMessage(scope.registration, message, scope.location.origin); + if ( message?.type === 'challenge' && nonEmptyString(message.challenge) ) { + await postToWindows(scope, { type: CHALLENGE_MESSAGE, challenge: message.challenge }); } } @@ -209,7 +224,7 @@ self.addEventListener('activate', (event) => { }); self.addEventListener('push', (event) => { - event.waitUntil(showMessage(self.registration, readPushPayload(event.data), self.location.origin)); + event.waitUntil(handlePush(self, readPushPayload(event.data))); }); self.addEventListener('message', (event) => { diff --git a/src/gui/src/sw.test.js b/src/gui/src/sw.test.js index d98c2d83a..c891e2f83 100644 --- a/src/gui/src/sw.test.js +++ b/src/gui/src/sw.test.js @@ -319,6 +319,27 @@ describe('service worker pushsubscriptionchange', () => { }); }); +describe('service worker challenge push', () => { + it('shows it, then hands the challenge to this origin`s windows', async () => { + const desktop = windowClient(`${ORIGIN}/desktop`); + const other = windowClient('https://elsewhere.example/'); + const worker = loadWorker({ windows: [desktop, other] }); + await worker.fire('push', { + data: pushData({ puter: { type: 'challenge', challenge: 'c.sig', uid: 'n1', title: 'Notifications are on', group: 'puter-push-challenge' } }), + }); + expect(worker.shown.map((n) => n.title)).toEqual(['Notifications are on']); + expect(desktop.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' }); + expect(other.postMessage).not.toHaveBeenCalled(); + }); + + it('posts nothing for an ordinary push', async () => { + const desktop = windowClient(`${ORIGIN}/desktop`); + const worker = loadWorker({ windows: [desktop] }); + await worker.fire('push', { data: pushData({ puter: { uid: 'n1', title: 'Hi' } }) }); + expect(desktop.postMessage).not.toHaveBeenCalled(); + }); +}); + describe('service worker lifecycle', () => { it('takes over at once and adds no fetch handler', async () => { const worker = loadWorker(); diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 5e55fc464..533146fa6 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -224,9 +224,12 @@ export type { NotificationListener, NotificationSender, OnNotificationOptions, + OnPushOptions, PushDevice, PushDeviceRegistration, + PushHandler, PushPlatform, + PushShown, } from './types/modules/notifications/types.js'; // -- puter.os -- diff --git a/src/puter-js/src/modules/notifications/disable.js b/src/puter-js/src/modules/notifications/disable.js new file mode 100644 index 000000000..6e18efeeb --- /dev/null +++ b/src/puter-js/src/modules/notifications/disable.js @@ -0,0 +1,19 @@ +import { removeWebPush, webPushSupported, workerFor } from './lib/webPush.js'; + +/** + * Turns push notifications off for this app in this browser: ends the browser's + * subscription and removes it from Puter. Notifications still arrive live while + * the app is open. + * + * @this {import('./index.js').NotificationsModule} + * @param {{ serviceWorker?: string }} [options] + * @returns {Promise} Whether there was a subscription to remove. + */ +export async function disable(options = {}) { + const { puter } = this; + if (puter.env !== 'web' && puter.env !== 'gui') return false; + if (!webPushSupported()) return false; + return await removeWebPush(puter, { + worker: workerFor(puter.env, options.serviceWorker), + }); +} diff --git a/src/puter-js/src/modules/notifications/enable.js b/src/puter-js/src/modules/notifications/enable.js new file mode 100644 index 000000000..0b80b24a6 --- /dev/null +++ b/src/puter-js/src/modules/notifications/enable.js @@ -0,0 +1,34 @@ +import { needsSignIn, signInVisitor } from '../events/lib/signIn.js'; +import { syncWebPush, webPushSupported, workerFor } from './lib/webPush.js'; + +/** + * Turns on push notifications for this app in this browser. Call it from a + * click: the browser's prompt is asked before anything else. + * + * Inside the Puter desktop this is `enablePush()`. On a website it asks for + * notification permission on the site's own origin, then subscribes through the + * site's service worker (`/puter-sw.js` unless `serviceWorker` names another) + * and confirms the subscription with Puter. + * + * @this {import('./index.js').NotificationsModule} + * @param {{ serviceWorker?: string }} [options] + * @returns {Promise} Whether this browser now receives pushes. + */ +export async function enable(options = {}) { + const { puter } = this; + if (puter.env === 'app') return this.enablePush(); + if (!webPushSupported()) return false; + + // Before any await, so the click's activation covers the prompt. + const asking = + Notification.permission === 'default' + ? Notification.requestPermission() + : Promise.resolve(Notification.permission); + if ((await asking) !== 'granted') return false; + + if (needsSignIn(puter)) await signInVisitor(puter); + await syncWebPush(puter, { + worker: workerFor(puter.env, options.serviceWorker), + }); + return true; +} diff --git a/src/puter-js/src/modules/notifications/index.js b/src/puter-js/src/modules/notifications/index.js index 7afb9659d..d5a591892 100644 --- a/src/puter-js/src/modules/notifications/index.js +++ b/src/puter-js/src/modules/notifications/index.js @@ -1,15 +1,26 @@ import EventListener from '../../lib/EventListener.js'; +import { needsSignIn } from '../events/lib/signIn.js'; +import { disable } from './disable.js'; +import { enable } from './enable.js'; import { enablePush } from './enablePush.js'; +import { CLICKS_STORE, idbDelete, idbGet } from './lib/pushStore.js'; import { toClick } from './lib/shapes.js'; import { listDevices } from './listDevices.js'; import { notify } from './notify.js'; import { onNotification } from './onNotification.js'; +import { onPush, shownFrom } from './onPush.js'; import { registerDevice } from './registerDevice.js'; import { removeDevice } from './removeDevice.js'; import { hasPermission, requestPermission } from './requestPermission.js'; /** The desktop's broadcast for a click on one of this app's notifications. */ export const CLICK_BROADCAST = 'notificationClick'; +/** What `push-sw.js` posts to an open page of the app when a push is tapped. */ +export const WORKER_CLICK_MESSAGE = 'puter-notification-click'; +/** What a worker posts when the browser replaced the push subscription. */ +export const SUBSCRIPTION_CHANGED_MESSAGE = 'puter-push-subscription-changed'; +/** Query parameter `push-sw.js` adds to a page it opens for a tap. */ +export const CLICK_PARAM = 'puter.notification'; /** * An app's notifications to the user it runs for. @@ -20,6 +31,9 @@ export class NotificationsModule extends EventListener { // Rebound in the constructor so destructured calls keep `this`. notify = notify; onNotification = onNotification; + enable = enable; + disable = disable; + onPush = onPush; requestPermission = requestPermission; hasPermission = hasPermission; enablePush = enablePush; @@ -28,6 +42,14 @@ export class NotificationsModule extends EventListener { removeDevice = removeDevice; #listening = false; + /** @type {Map>} */ + #pushHandlers = new Map(); + /** @type {Promise<{ off: () => Promise } | null> | null} */ + #pushFeed = null; + /** @type {(() => unknown) | null} */ + #stopWaitingForAuth = null; + /** @type {(() => Promise) | null} */ + #resync = null; /** @param {import('../../index.js').Puter} puter */ constructor(puter) { @@ -41,6 +63,9 @@ export class NotificationsModule extends EventListener { for (const name of [ 'notify', 'onNotification', + 'enable', + 'disable', + 'onPush', 'requestPermission', 'hasPermission', 'enablePush', @@ -76,9 +101,176 @@ export class NotificationsModule extends EventListener { ); }); replaying = false; + if (this.puter.env === 'web') this.#listenForWorkerClicks(handler); } return result; } + + /** + * @internal `onPush`'s live side: one notifications feed for every key. + * Resolves `off`, which resolves whether `key` has no handlers left. + */ + async _addPushHandler(key, handler, onError) { + const entry = { handler, onError }; + const entries = this.#pushHandlers.get(key) ?? new Set(); + entries.add(entry); + this.#pushHandlers.set(key, entries); + this.#startPushFeed(); + return async () => { + entries.delete(entry); + if (entries.size === 0 && this.#pushHandlers.get(key) === entries) + this.#pushHandlers.delete(key); + if (this.#pushHandlers.size === 0) await this.#stopPushFeed(); + return !this.#pushHandlers.has(key); + }; + } + + /** @internal Registers again when the worker reports a replaced subscription. */ + _resyncOnSubscriptionChange(sync) { + const listening = this.#resync !== null; + this.#resync = sync; + if (listening) return; + globalThis.navigator?.serviceWorker?.addEventListener( + 'message', + (event) => { + if (event.data?.type !== SUBSCRIPTION_CHANGED_MESSAGE) return; + if (this.#pushHandlers.size > 0) void this.#resync?.(); + }, + ); + } + + #startPushFeed() { + if (this.#pushFeed || this.#stopWaitingForAuth) return; + // A visitor who hasn't signed in yet: start once they do. + if (needsSignIn(this.puter)) { + this.#stopWaitingForAuth = this.puter.onAuthStateChanged(() => { + if (needsSignIn(this.puter)) return; + this.#stopWaitingForAuth?.(); + this.#stopWaitingForAuth = null; + this.#startPushFeed(); + }); + return; + } + const onError = (error) => this.#reportPush(error); + this.#pushFeed = (async () => { + try { + return await this.onNotification( + (notification) => this.#dispatchPush(notification), + { catchUp: false, onError }, + ); + } catch (error) { + onError(error); + this.#pushFeed = null; + return null; + } + })(); + } + + async #stopPushFeed() { + this.#stopWaitingForAuth?.(); + this.#stopWaitingForAuth = null; + const feed = this.#pushFeed; + this.#pushFeed = null; + await (await feed)?.off(); + } + + #reportPush(error) { + const handlers = [...this.#pushHandlers.values()].flatMap((set) => + [...set].map((entry) => entry.onError).filter(Boolean), + ); + if (handlers.length === 0) + console.error('[puter.notifications] push listener failed', error); + for (const onError of new Set(handlers)) onError(error); + } + + async #dispatchPush(notification) { + const entries = this.#pushHandlers.get(notification.channel ?? ''); + if (!entries) return; + let result; + for (const { handler, onError } of [...entries]) { + try { + result = (await handler(notification)) ?? result; + } catch (error) { + if (onError) onError(error); + else + console.error( + '[puter.notifications] push handler failed', + error, + ); + } + } + await this.#showIfHidden(notification, result); + } + + /** A connected page that is hidden shows what the push would have. */ + async #showIfHidden(notification, result) { + if (this.puter.env !== 'web') return; + if (globalThis.document?.visibilityState !== 'hidden') return; + if (globalThis.Notification?.permission !== 'granted') return; + try { + const registration = + await globalThis.navigator.serviceWorker?.getRegistration(); + if (!registration) return; + const shown = shownFrom(notification, result); + const origin = globalThis.location.origin; + const url = new URL(shown.url ?? '/', origin); + const { title, url: _url, ...options } = shown; + await registration.showNotification(title, { + ...options, + data: { + puter: { + uid: notification.uid, + target: notification.target, + data: notification.data, + url: url.origin === origin ? url.href : `${origin}/`, + }, + }, + }); + } catch (error) { + console.warn( + '[puter.notifications] could not show a notification', + error, + ); + } + } + + /** + * Taps from `push-sw.js`: posted to an open page, or carried by the URL of + * one it opened. + */ + #listenForWorkerClicks(firstHandler) { + const container = globalThis.navigator?.serviceWorker; + container?.addEventListener('message', (event) => { + if (event.data?.type !== WORKER_CLICK_MESSAGE) return; + const click = toClick(event.data.notification); + if (click) this.emit('click', click); + }); + container?.startMessages?.(); + + const url = globalThis.location + ? new URL(globalThis.location.href) + : null; + const uid = url?.searchParams.get(CLICK_PARAM); + if (!uid) return; + url.searchParams.delete(CLICK_PARAM); + globalThis.history?.replaceState?.( + globalThis.history.state, + '', + url.href, + ); + void (async () => { + try { + const click = toClick(await idbGet(CLICKS_STORE, uid)); + await idbDelete(CLICKS_STORE, uid); + if (click) firstHandler(click); + } catch (error) { + console.warn( + '[puter.notifications] could not read a tap', + error, + ); + } + })(); + } } /** diff --git a/src/puter-js/src/modules/notifications/lib/pushStore.js b/src/puter-js/src/modules/notifications/lib/pushStore.js new file mode 100644 index 000000000..fcc0fb640 --- /dev/null +++ b/src/puter-js/src/modules/notifications/lib/pushStore.js @@ -0,0 +1,37 @@ +// IndexedDB shared with `push-sw.js` on the same origin; names must match it. +export const PUSH_DB_NAME = 'puter-push'; +export const HANDLERS_STORE = 'handlers'; +export const CLICKS_STORE = 'clicks'; + +const open = (idb) => + new Promise((resolve, reject) => { + const req = idb.open(PUSH_DB_NAME, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(HANDLERS_STORE); + req.result.createObjectStore(CLICKS_STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + +const run = async (idb, store, mode, action) => { + const db = await open(idb); + try { + return await new Promise((resolve, reject) => { + const req = action(db.transaction(store, mode).objectStore(store)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } finally { + db.close(); + } +}; + +export const idbGet = (store, key, idb = globalThis.indexedDB) => + run(idb, store, 'readonly', (s) => s.get(key)); + +export const idbPut = (store, key, value, idb = globalThis.indexedDB) => + run(idb, store, 'readwrite', (s) => s.put(value, key)); + +export const idbDelete = (store, key, idb = globalThis.indexedDB) => + run(idb, store, 'readwrite', (s) => s.delete(key)); diff --git a/src/puter-js/src/modules/notifications/lib/webPush.js b/src/puter-js/src/modules/notifications/lib/webPush.js new file mode 100644 index 000000000..5e372d10b --- /dev/null +++ b/src/puter-js/src/modules/notifications/lib/webPush.js @@ -0,0 +1,214 @@ +import { PuterJSError } from '../../../lib/PuterJSError.js'; +import { request } from './api.js'; + +/** Posted by the service worker when the server's confirmation push arrives. */ +export const CHALLENGE_MESSAGE = 'puter-push-challenge'; +/** Where an app's own origin serves the worker that imports `push-sw.js`. */ +export const DEFAULT_APP_WORKER = '/puter-sw.js'; +/** The Puter GUI's own worker. */ +export const GUI_WORKER = '/sw.js'; + +const CHALLENGE_TIMEOUT_MS = 30_000; +const WORKER_START_TIMEOUT_MS = 10_000; + +export const webPushSupported = (g = globalThis) => + typeof g.navigator?.serviceWorker?.register === 'function' && + 'PushManager' in g && + 'Notification' in g; + +export const fromBase64Url = (value) => { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +}; + +const sameKey = (buffer, key) => { + if (!buffer) return false; + const bytes = new Uint8Array(buffer); + return bytes.length === key.length && bytes.every((b, i) => b === key[i]); +}; + +/** The worker for this page; `GUI_WORKER` on the GUI, the app's own elsewhere. */ +export const workerFor = (env, serviceWorker) => + env === 'gui' + ? { url: GUI_WORKER, scope: '/' } + : { url: serviceWorker ?? DEFAULT_APP_WORKER }; + +const untilActive = (registration) => { + if (registration.active) return Promise.resolve(registration); + const worker = registration.installing ?? registration.waiting; + if (!worker) return Promise.resolve(registration); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + worker.removeEventListener('statechange', check); + reject( + new PuterJSError( + 'The push service worker did not start', + 'push_worker_unavailable', + ), + ); + }, WORKER_START_TIMEOUT_MS); + function check() { + if (worker.state !== 'activated' && worker.state !== 'redundant') + return; + clearTimeout(timer); + worker.removeEventListener('statechange', check); + if (worker.state === 'activated') resolve(registration); + else + reject( + new PuterJSError( + 'The push service worker failed to install', + 'push_worker_unavailable', + ), + ); + } + worker.addEventListener('statechange', check); + check(); + }); +}; + +const registerWorker = async (nav, worker) => { + try { + const registration = await nav.serviceWorker.register(worker.url, { + ...(worker.scope ? { scope: worker.scope } : {}), + updateViaCache: 'none', + }); + return await untilActive(registration); + } catch (error) { + if (error instanceof PuterJSError) throw error; + throw new PuterJSError( + `Could not register the push service worker at ${worker.url}`, + 'push_worker_unavailable', + { cause: error }, + ); + } +}; + +/** + * Resolves the next challenge the worker hands this page; `cancel` stops + * waiting. + */ +const nextChallenge = (nav) => { + let cancel = () => {}; + const promise = new Promise((resolve, reject) => { + const onMessage = (event) => { + const data = event.data; + if (data?.type !== CHALLENGE_MESSAGE) return; + if (typeof data.challenge !== 'string') return; + cancel(); + resolve(data.challenge); + }; + const timer = setTimeout(() => { + cancel(); + reject( + new PuterJSError( + 'The confirmation push did not arrive', + 'push_verification_timeout', + ), + ); + }, CHALLENGE_TIMEOUT_MS); + cancel = () => { + clearTimeout(timer); + nav.serviceWorker.removeEventListener('message', onMessage); + }; + nav.serviceWorker.addEventListener('message', onMessage); + nav.serviceWorker.startMessages?.(); + }); + return { promise, cancel: () => cancel() }; +}; + +const subscriptionBody = (subscription) => { + const { endpoint, keys } = subscription.toJSON(); + return { endpoint, keys: { p256dh: keys?.p256dh, auth: keys?.auth } }; +}; + +/** + * 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. + * + * @returns {Promise>} The server's device record. + */ +export async function syncWebPush( + puter, + { worker, label, nav = globalThis.navigator } = {}, +) { + 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); + + let subscription = await registration.pushManager.getSubscription(); + if ( + subscription && + !sameKey(subscription.options?.applicationServerKey, key) + ) { + await subscription.unsubscribe(); + subscription = null; + } + subscription ??= await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }); + + const body = { + subscription: subscriptionBody(subscription), + ...(label ? { label } : {}), + }; + // Listening first: the confirmation can arrive before the POST returns. + const challenge = nextChallenge(nav); + try { + const result = await request( + puter, + 'POST', + '/push/web/subscriptions', + body, + ); + if (result.status === 'active') return result.device; + const { device } = await request( + puter, + 'POST', + '/push/web/subscriptions/verify', + { + subscription: body.subscription, + challenge: await challenge.promise, + }, + ); + return device; + } finally { + challenge.cancel(); + } +} + +/** Ends this browser's subscription for `worker` and tells the server. */ +export async function removeWebPush( + puter, + { worker, nav = globalThis.navigator } = {}, +) { + const registration = await nav.serviceWorker.getRegistration( + worker.scope ?? worker.url, + ); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription) return false; + try { + await request(puter, 'DELETE', '/push/web/subscriptions', { + endpoint: subscription.endpoint, + }); + } finally { + await subscription.unsubscribe(); + } + return true; +} + +/** This browser's subscription for `worker`, or null. */ +export async function currentWebPush({ worker, nav = globalThis.navigator }) { + const registration = await nav.serviceWorker.getRegistration( + worker.scope ?? worker.url, + ); + return (await registration?.pushManager.getSubscription()) ?? null; +} diff --git a/src/puter-js/src/modules/notifications/onPush.js b/src/puter-js/src/modules/notifications/onPush.js new file mode 100644 index 000000000..12233a51d --- /dev/null +++ b/src/puter-js/src/modules/notifications/onPush.js @@ -0,0 +1,93 @@ +import { PuterJSError } from '../../lib/PuterJSError.js'; +import { needsSignIn } from '../events/lib/signIn.js'; +import { HANDLERS_STORE, idbDelete, idbPut } from './lib/pushStore.js'; +import { syncWebPush, webPushSupported, workerFor } from './lib/webPush.js'; + +/** Same rule the server applies to a notification's `channel`. */ +const KEY_PATTERN = /^[a-z0-9-]{1,32}$/; + +/** Fields a handler's result may set on the shown notification. */ +const SHOWN_FIELDS = ['title', 'body', 'icon', 'badge', 'image', 'tag', 'url']; + +/** The handler's result, reduced to what a notification can show. */ +export const shownFrom = (notification, result) => { + const shown = { + title: notification.title || 'New notification', + ...(notification.text ? { body: notification.text } : {}), + ...(notification.icon ? { icon: notification.icon } : {}), + tag: notification.group ?? notification.uid, + ...(notification.target ? { url: notification.target } : {}), + }; + if (!result || typeof result !== 'object') return shown; + for (const field of SHOWN_FIELDS) { + if (typeof result[field] === 'string' && result[field] !== '') + shown[field] = result[field]; + } + return shown; +}; + +const report = (onError, error) => { + if (onError) onError(error); + else console.error('[puter.notifications] push handler failed', error); +}; + +/** + * Handles this app's notifications sent with `channel: key`, live while the app + * is connected and as a browser push while it isn't. + * + * The handler gets the notification and may return `{ title, body, icon, badge, + * image, tag, url }` to change what is shown. On a website it is also saved for + * `push-sw.js`, which runs it when a push arrives with no page open, so it must + * be self-contained (see the docs for the limits). Once the user turned + * notifications on with `enable()`, this keeps the browser's subscription + * current without asking again. + * + * @this {import('./index.js').NotificationsModule} + * @param {string} key + * @param {import('./types.js').PushHandler} handler + * @param {import('./types.js').OnPushOptions} [options] + * @returns {Promise} + */ +export async function onPush(key, handler, options = {}) { + if (typeof key !== 'string' || !KEY_PATTERN.test(key)) { + throw new PuterJSError( + '`key` must be 1 to 32 lowercase letters, digits or -', + 'invalid_argument', + ); + } + if (typeof handler !== 'function') { + throw new PuterJSError('handler must be a function', 'invalid_handler'); + } + + const { puter } = this; + const web = puter.env === 'web'; + if (web) await idbPut(HANDLERS_STORE, key, handler.toString()); + + const off = await this._addPushHandler(key, handler, options.onError); + + if ( + web && + !needsSignIn(puter) && + webPushSupported() && + Notification.permission === 'granted' + ) { + const worker = workerFor(puter.env, options.serviceWorker); + const sync = async () => { + try { + await syncWebPush(puter, { worker }); + } catch (error) { + report(options.onError, error); + } + }; + // Not awaited: a new subscription waits on a confirmation push. + void sync(); + this._resyncOnSubscriptionChange(sync); + } + + return { + off: async () => { + const lastForKey = await off(); + if (web && lastForKey) await idbDelete(HANDLERS_STORE, key); + }, + }; +} diff --git a/src/puter-js/src/modules/notifications/types.js b/src/puter-js/src/modules/notifications/types.js index d64559336..ea989d6ea 100644 --- a/src/puter-js/src/modules/notifications/types.js +++ b/src/puter-js/src/modules/notifications/types.js @@ -90,7 +90,43 @@ * @property {unknown} data The `data` it was sent with, or `null`. */ -/** @typedef {'web' | 'ios' | 'android'} PushPlatform */ +/** + * What a push handler may return to change the shown notification. `url` is a + * path or URL on the app's own origin, opened when it is tapped. + * + * @typedef {Object} PushShown + * @property {string} [title] + * @property {string} [body] + * @property {string} [icon] + * @property {string} [badge] + * @property {string} [image] + * @property {string} [tag] + * @property {string} [url] + */ + +/** + * Called for each notification sent with the handler's key. It runs in the page + * while the app is connected and, on websites, in the service worker when a + * push arrives: it must be self-contained there (no outside variables or + * imports), finish within 5 seconds, and not touch the DOM. + * + * @typedef {( + * notification: AppNotification, + * ) => PushShown | void | Promise} PushHandler + */ + +/** + * Options for {@link import('./onPush.js').onPush}. + * + * @typedef {Object} OnPushOptions + * @property {string} [serviceWorker] Path of the app's worker that imports + * `push-sw.js`. Default `/puter-sw.js`. + * @property {(error: Error & { code?: string }) => void} [onError] Called when + * the handler throws, the live listener stops, or keeping the browser's + * subscription current fails. + */ + +/** @typedef {'web' | 'webpush' | 'ios' | 'android'} PushPlatform */ /** * What `registerDevice()` takes. diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js new file mode 100644 index 000000000..737ebe714 --- /dev/null +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockRequest = vi.fn(); +vi.mock('./lib/api.js', () => ({ + request: (...args) => mockRequest(...args), +})); +const store = new Map(); +vi.mock('./lib/pushStore.js', () => ({ + HANDLERS_STORE: 'handlers', + CLICKS_STORE: 'clicks', + idbGet: vi.fn(async (s, k) => store.get(`${s}:${k}`)), + idbPut: vi.fn(async (s, k, v) => void store.set(`${s}:${k}`, v)), + idbDelete: vi.fn(async (s, k) => void store.delete(`${s}:${k}`)), +})); + +const { syncWebPush, removeWebPush, fromBase64Url, workerFor } = + await import('./lib/webPush.js'); +const { NotificationsModule } = await import('./index.js'); +const { shownFrom } = await import('./onPush.js'); + +// 65 bytes, 0x04 first, as base64url. +const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url'); + +const makeSubscription = (key = fromBase64Url(KEY)) => ({ + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + options: { applicationServerKey: key.buffer }, + toJSON: () => ({ + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + expirationTime: null, + keys: { p256dh: 'p', auth: 'a' }, + }), + unsubscribe: vi.fn(async () => true), +}); + +const makeNav = ({ existing = null } = {}) => { + const listeners = new Set(); + const pushManager = { + getSubscription: vi.fn(async () => existing), + subscribe: vi.fn(async () => makeSubscription()), + }; + const registration = { active: {}, pushManager }; + return { + pushManager, + registration, + post: (data) => { + for (const fn of [...listeners]) fn({ data }); + }, + serviceWorker: { + register: vi.fn(async () => registration), + getRegistration: vi.fn(async () => registration), + addEventListener: (type, fn) => listeners.add(fn), + removeEventListener: (type, fn) => listeners.delete(fn), + startMessages: vi.fn(), + }, + }; +}; + +const puter = { env: 'web', authToken: 't', APIOrigin: 'https://api.test' }; +const worker = workerFor('web'); + +beforeEach(() => { + mockRequest.mockReset(); + store.clear(); +}); + +describe('syncWebPush', () => { + it('refreshes a subscription the server already has', async () => { + const nav = makeNav({ existing: makeSubscription() }); + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') return { publicKey: KEY }; + return { status: 'active', device: { uid: 'd1' } }; + }); + await expect(syncWebPush(puter, { worker, nav })).resolves.toEqual({ + uid: 'd1', + }); + expect(nav.serviceWorker.register).toHaveBeenCalledWith( + '/puter-sw.js', + { + updateViaCache: 'none', + }, + ); + expect(nav.pushManager.subscribe).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledWith( + puter, + 'POST', + '/push/web/subscriptions', + { + subscription: { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + keys: { p256dh: 'p', auth: 'a' }, + }, + }, + ); + }); + + it('confirms a new subscription with the challenge the worker hands back', async () => { + const nav = makeNav(); + mockRequest.mockImplementation(async (_p, method, route, body) => { + if (route === '/push/web/key') return { publicKey: KEY }; + if (route === '/push/web/subscriptions') { + queueMicrotask(() => + nav.post({ + type: 'puter-push-challenge', + challenge: 'c.sig', + }), + ); + return { status: 'pending' }; + } + return { device: { uid: 'd2', challenge: body.challenge } }; + }); + await expect(syncWebPush(puter, { worker, nav })).resolves.toEqual({ + uid: 'd2', + challenge: 'c.sig', + }); + expect(nav.pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: fromBase64Url(KEY), + }); + }); + + it('replaces a subscription made with another key', async () => { + const stale = makeSubscription(new Uint8Array([4, 1, 2])); + const nav = makeNav({ existing: stale }); + mockRequest.mockImplementation(async (_p, method, route) => + route === '/push/web/key' + ? { publicKey: KEY } + : { status: 'active', device: { uid: 'd3' } }, + ); + await syncWebPush(puter, { worker, nav }); + expect(stale.unsubscribe).toHaveBeenCalled(); + expect(nav.pushManager.subscribe).toHaveBeenCalled(); + }); + + it('fails with push_unavailable when the server has no key', async () => { + const nav = makeNav(); + mockRequest.mockResolvedValue({}); + await expect(syncWebPush(puter, { worker, nav })).rejects.toMatchObject( + { + code: 'push_unavailable', + }, + ); + }); +}); + +describe('removeWebPush', () => { + it('tells the server, then ends the subscription', async () => { + const subscription = makeSubscription(); + const nav = makeNav({ existing: subscription }); + mockRequest.mockResolvedValue({ removed: true }); + await expect(removeWebPush(puter, { worker, nav })).resolves.toBe(true); + expect(mockRequest).toHaveBeenCalledWith( + puter, + 'DELETE', + '/push/web/subscriptions', + { endpoint: subscription.endpoint }, + ); + expect(subscription.unsubscribe).toHaveBeenCalled(); + }); +}); + +describe('workerFor', () => { + it('uses the GUI worker on the GUI and the app`s own elsewhere', () => { + expect(workerFor('gui')).toEqual({ url: '/sw.js', scope: '/' }); + expect(workerFor('web')).toEqual({ url: '/puter-sw.js' }); + expect(workerFor('web', '/js/sw.js')).toEqual({ url: '/js/sw.js' }); + }); +}); + +describe('shownFrom', () => { + const notification = { + uid: 'n1', + title: 'Hi', + text: 'body', + icon: null, + target: '/chat/1', + group: null, + }; + + it('defaults to the notification`s own fields', () => { + expect(shownFrom(notification, undefined)).toEqual({ + title: 'Hi', + body: 'body', + tag: 'n1', + url: '/chat/1', + }); + }); + + it('takes only the shown fields a handler returns as strings', () => { + expect( + shownFrom(notification, { title: 'New', body: 7, onclick: 'x' }), + ).toEqual({ title: 'New', body: 'body', tag: 'n1', url: '/chat/1' }); + }); +}); + +describe('onPush', () => { + let feedHandler; + const makeModule = () => { + const module = new NotificationsModule({ + ...puter, + onAuthStateChanged: () => () => {}, + }); + module.onNotification = vi.fn(async (handler) => { + feedHandler = handler; + return { off: vi.fn(async () => {}) }; + }); + return module; + }; + + afterEach(() => { + feedHandler = undefined; + }); + + it('rejects a key the server would not accept as a channel', async () => { + const module = makeModule(); + await expect(module.onPush('Chat!', () => {})).rejects.toMatchObject({ + code: 'invalid_argument', + }); + }); + + it('saves the handler for the worker and runs it for its channel only', async () => { + const module = makeModule(); + const chat = vi.fn(); + const listener = await module.onPush('chat', chat); + expect(store.get('handlers:chat')).toBe(chat.toString()); + expect(module.onNotification).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ catchUp: false }), + ); + + await feedHandler({ uid: 'n1', channel: 'other' }); + await feedHandler({ uid: 'n2', channel: 'chat' }); + expect(chat).toHaveBeenCalledTimes(1); + expect(chat).toHaveBeenCalledWith({ uid: 'n2', channel: 'chat' }); + + await listener.off(); + expect(store.has('handlers:chat')).toBe(false); + }); + + it('keeps the saved handler while another listener for the key remains', async () => { + const module = makeModule(); + const first = await module.onPush('chat', () => {}); + await module.onPush('chat', () => {}); + await first.off(); + expect(store.has('handlers:chat')).toBe(true); + }); + + it('starts one feed for every key', async () => { + const module = makeModule(); + await module.onPush('chat', () => {}); + await module.onPush('mail', () => {}); + expect(module.onNotification).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/puter-js/src/push-sw.js b/src/puter-js/src/push-sw.js new file mode 100644 index 000000000..04cb146f7 --- /dev/null +++ b/src/puter-js/src/push-sw.js @@ -0,0 +1,248 @@ +// Puter push notifications on an app's own origin, served at +// https://js.puter.com/v2/push-sw.js. The app serves a worker (default +// `/puter-sw.js`) that contains: +// +// importScripts('https://js.puter.com/v2/push-sw.js'); +// +// It shows each Puter push, running the handler the page saved with +// `puter.notifications.onPush(key, handler)`, and routes taps back to the app. +// A handler that needs imports can be registered in the worker itself with +// `puterPush.onPush(key, handler)`, which wins over a saved one. +// +// No install, activate or fetch handlers, so importing it into an existing +// worker leaves that worker's lifecycle alone. Pushes without a `puter` +// message are left to the app's own listeners. +(() => { + if (self.puterPush) return; + + // Shared with puter.js on the same origin (modules/notifications/lib/pushStore.js). + const DB_NAME = 'puter-push'; + const HANDLERS = 'handlers'; + const CLICKS = 'clicks'; + const HANDLER_TIMEOUT_MS = 5000; + const CLICK_KEEP_MS = 5 * 60 * 1000; + const DEFAULT_TITLE = 'New notification'; + const CHALLENGE_MESSAGE = 'puter-push-challenge'; + const CLICK_MESSAGE = 'puter-notification-click'; + const CHANGED_MESSAGE = 'puter-push-subscription-changed'; + const CLICK_PARAM = 'puter.notification'; + const SHOWN_FIELDS = ['title', 'body', 'icon', 'badge', 'image', 'tag', 'url']; + + const registered = new Map(); + + const openDB = () => new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(HANDLERS); + req.result.createObjectStore(CLICKS); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + const idb = async (store, mode, action) => { + const db = await openDB(); + try { + return await new Promise((resolve, reject) => { + const req = action(db.transaction(store, mode).objectStore(store)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } finally { + db.close(); + } + }; + + const str = (value) => (typeof value === 'string' && value !== '' ? value : null); + + function readMessage (data) { + let payload; + try { + payload = data ? data.json() : null; + } catch { + return null; + } + let message = payload && typeof payload === 'object' + ? (payload.puter ?? payload.data?.puter) + : null; + if ( typeof message === 'string' ) { + try { + message = JSON.parse(message); + } catch { + return null; + } + } + return message && typeof message === 'object' ? message : null; + } + + // The shape `onPush` handlers get in the page. + const toNotification = (message) => ({ + uid: str(message.uid) ?? '', + title: str(message.title) ?? '', + text: str(message.body), + icon: str(message.icon), + target: str(message.target?.path), + data: message.data ?? null, + group: str(message.group), + channel: str(message.channel), + createdAt: Math.floor(Date.now() / 1000), + app: message.app && typeof message.app === 'object' ? message.app : null, + }); + + async function handlerFor (channel) { + if ( ! channel ) return null; + if ( registered.has(channel) ) return registered.get(channel); + const source = await idb(HANDLERS, 'readonly', (s) => s.get(channel)); + if ( typeof source !== 'string' ) return null; + const fn = new Function(`"use strict"; return (${source});`)(); + return typeof fn === 'function' ? fn : null; + } + + const withTimeout = (promise) => Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('push handler timed out')), HANDLER_TIMEOUT_MS)), + ]); + + async function render (notification) { + const shown = { + title: notification.title || DEFAULT_TITLE, + ...(notification.text ? { body: notification.text } : {}), + ...(notification.icon ? { icon: notification.icon } : {}), + tag: notification.group ?? notification.uid, + ...(notification.target ? { url: notification.target } : {}), + }; + try { + const handler = await handlerFor(notification.channel); + const result = handler ? await withTimeout(Promise.resolve().then(() => handler(notification))) : null; + if ( result && typeof result === 'object' ) { + for ( const field of SHOWN_FIELDS ) { + if ( str(result[field]) ) shown[field] = result[field]; + } + } + } catch ( err ) { + console.warn('[puter] push handler failed', err); + } + return shown; + } + + // Taps only ever open this origin. + function sameOriginUrl (url) { + try { + const parsed = new URL(url || '/', self.location.origin); + return parsed.origin === self.location.origin ? parsed.href : `${self.location.origin}/`; + } catch { + return `${self.location.origin}/`; + } + } + + async function windows () { + const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + return all.filter((c) => { + try { + return new URL(c.url).origin === self.location.origin; + } catch { + return false; + } + }); + } + + async function showPush (message) { + if ( message.type === 'challenge' ) { + await self.registration.showNotification(str(message.title) ?? DEFAULT_TITLE, { + ...(str(message.body) ? { body: message.body } : {}), + tag: str(message.group) ?? 'puter-push-challenge', + }); + if ( str(message.challenge) ) { + for ( const win of await windows() ) win.postMessage({ type: CHALLENGE_MESSAGE, challenge: message.challenge }); + } + return; + } + const notification = toNotification(message); + const { title, url, ...options } = await render(notification); + const data = { + puter: { + uid: notification.uid, + target: notification.target, + data: notification.data, + url: sameOriginUrl(url), + }, + }; + try { + await self.registration.showNotification(title, { ...options, data }); + } catch { + // Browsers count a push with nothing shown against the site. + await self.registration.showNotification(DEFAULT_TITLE, { data }); + } + } + + async function forgetOldClicks () { + const cutoff = Date.now() - CLICK_KEEP_MS; + await idb(CLICKS, 'readwrite', (s) => { + const req = s.openCursor(); + req.addEventListener('success', () => { + const cursor = req.result; + if ( ! cursor ) return; + if ( ! (cursor.value?.at > cutoff) ) cursor.delete(); + cursor.continue(); + }); + return req; + }); + } + + async function routeClick (puter) { + const click = { uid: puter.uid, target: puter.target ?? null, data: puter.data ?? null }; + const url = sameOriginUrl(puter.url); + const open = await windows(); + const win = open.find((c) => c.url === url) ?? open.find((c) => c.focused) ?? open[0]; + if ( win ) { + win.postMessage({ type: CLICK_MESSAGE, notification: click }); + try { + await win.focus(); + } catch { + // focus is refused once the click's window has passed + } + return; + } + if ( click.uid ) { + try { + await forgetOldClicks(); + await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now() }, click.uid)); + } catch ( err ) { + console.warn('[puter] could not keep a tap', err); + } + } + const opened = new URL(url); + if ( click.uid ) opened.searchParams.set(CLICK_PARAM, click.uid); + await self.clients.openWindow(opened.href); + } + + self.addEventListener('push', (event) => { + const message = readMessage(event.data); + if ( ! message ) return; + event.waitUntil(showPush(message)); + }); + + self.addEventListener('notificationclick', (event) => { + const puter = event.notification.data?.puter; + if ( ! puter || typeof puter !== 'object' ) return; + event.notification.close(); + event.waitUntil(routeClick(puter)); + }); + + self.addEventListener('pushsubscriptionchange', (event) => { + // The worker has no Puter session; an open page registers the new one. + event.waitUntil((async () => { + for ( const win of await windows() ) win.postMessage({ type: CHANGED_MESSAGE }); + })()); + }); + + self.puterPush = { + /** Handles pushes for `key` in the worker, over a handler the page saved. */ + onPush (key, handler) { + if ( typeof key !== 'string' || typeof handler !== 'function' ) { + throw new TypeError('onPush(key, handler) takes a string and a function'); + } + registered.set(key, handler); + }, + }; +})(); diff --git a/src/puter-js/src/push-sw.test.js b/src/puter-js/src/push-sw.test.js new file mode 100644 index 000000000..6c85d2733 --- /dev/null +++ b/src/puter-js/src/push-sw.test.js @@ -0,0 +1,223 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const SOURCE = readFileSync( + fileURLToPath(new URL('./push-sw.js', import.meta.url)), + 'utf8', +); +const ORIGIN = 'https://app.example'; + +/** Enough of IndexedDB for get/put and a cursor walk. */ +const fakeIndexedDB = (stores) => { + const request = (run) => { + const req = { listeners: [] }; + req.addEventListener = (_t, fn) => req.listeners.push(fn); + queueMicrotask(() => { + try { + req.result = run(req); + req.onsuccess?.(); + for (const fn of req.listeners) fn(); + } catch (error) { + req.error = error; + req.onerror?.(); + } + }); + return req; + }; + const objectStore = (name) => { + const map = (stores[name] ??= new Map()); + return { + get: (key) => request(() => map.get(key)), + put: (value, key) => request(() => void map.set(key, value)), + openCursor: () => request(() => null), + }; + }; + return { + open: () => { + const req = request(() => ({ + transaction: (name) => ({ objectStore: () => objectStore(name) }), + close: () => {}, + createObjectStore: () => {}, + })); + return req; + }, + }; +}; + +const windowClient = (url, over = {}) => ({ + url, + focused: false, + focus: vi.fn(async () => {}), + postMessage: vi.fn(), + ...over, +}); + +const loadWorker = ({ handlers = {}, windows = [] } = {}) => { + const listeners = {}; + const shown = []; + const stores = { handlers: new Map(Object.entries(handlers)) }; + const scope = { + location: { origin: ORIGIN }, + addEventListener: (type, fn) => { + listeners[type] = fn; + }, + registration: { + showNotification: vi.fn(async (title, options) => { + shown.push({ title, options }); + }), + }, + clients: { + matchAll: vi.fn(async () => windows), + openWindow: vi.fn(async () => null), + }, + }; + scope.self = scope; + const context = vm.createContext({ + ...scope, + URL, + JSON, + Math, + Map, + Promise, + Error, + TypeError, + Date, + console, + queueMicrotask, + setTimeout, + Function, + indexedDB: fakeIndexedDB(stores), + }); + context.self = context; + vm.runInContext(SOURCE, context); + const fire = async (type, event) => { + let pending = Promise.resolve(); + listeners[type]?.({ ...event, waitUntil: (p) => (pending = p) }); + await pending; + }; + return { context, scope, listeners, shown, stores, fire }; +}; + +const pushData = (payload) => ({ json: () => payload }); + +describe('push-sw push', () => { + it('leaves a push without a Puter message to the app', async () => { + const worker = loadWorker(); + await worker.fire('push', { data: pushData({ hello: 1 }) }); + expect(worker.shown).toEqual([]); + }); + + it('shows the message as sent when no handler is saved', async () => { + const worker = loadWorker(); + await worker.fire('push', { + data: pushData({ + puter: { uid: 'n1', title: 'Hi', body: 'there', target: { path: '/c/1' } }, + }), + }); + expect(worker.shown).toEqual([ + { + title: 'Hi', + options: { + body: 'there', + tag: 'n1', + data: { + puter: { + uid: 'n1', + target: '/c/1', + data: null, + url: `${ORIGIN}/c/1`, + }, + }, + }, + }, + ]); + }); + + it('runs the handler the page saved for the channel', async () => { + const worker = loadWorker({ + handlers: { + chat: '(n) => ({ title: n.app.name + ": " + n.title, url: "/chat" })', + }, + }); + await worker.fire('push', { + data: pushData({ + puter: { uid: 'n1', title: 'Hi', channel: 'chat', app: { uid: 'a', name: 'Chat' } }, + }), + }); + expect(worker.shown[0].title).toBe('Chat: Hi'); + expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/chat`); + }); + + it('prefers a handler registered in the worker', async () => { + const worker = loadWorker({ handlers: { chat: '() => ({ title: "saved" })' } }); + worker.context.puterPush.onPush('chat', () => ({ title: 'worker' })); + await worker.fire('push', { + data: pushData({ puter: { uid: 'n1', title: 'Hi', channel: 'chat' } }), + }); + expect(worker.shown[0].title).toBe('worker'); + }); + + it('still shows the defaults when the handler throws', async () => { + const worker = loadWorker({ handlers: { chat: '() => { throw new Error("x") }' } }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await worker.fire('push', { + data: pushData({ puter: { uid: 'n1', title: 'Hi', channel: 'chat' } }), + }); + expect(worker.shown[0].title).toBe('Hi'); + warn.mockRestore(); + }); + + it('never points a tap at another origin', async () => { + const worker = loadWorker({ handlers: { chat: '() => ({ url: "https://evil.example/x" })' } }); + await worker.fire('push', { + data: pushData({ puter: { uid: 'n1', title: 'Hi', channel: 'chat' } }), + }); + expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/`); + }); + + it('shows a challenge and hands it to this origin`s windows', async () => { + const mine = windowClient(`${ORIGIN}/`); + const other = windowClient('https://elsewhere.example/'); + const worker = loadWorker({ windows: [mine, other] }); + await worker.fire('push', { + data: pushData({ + puter: { type: 'challenge', challenge: 'c.sig', title: 'Notifications are on', group: 'puter-push-challenge' }, + }), + }); + expect(worker.shown.map((n) => n.title)).toEqual(['Notifications are on']); + expect(mine.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' }); + expect(other.postMessage).not.toHaveBeenCalled(); + }); +}); + +describe('push-sw notificationclick', () => { + const tap = (puter) => ({ notification: { data: { puter }, close: vi.fn() } }); + + it('hands the tap to an open window of the app', async () => { + const open = windowClient(`${ORIGIN}/chat`); + const worker = loadWorker({ windows: [open] }); + await worker.fire('notificationclick', tap({ uid: 'n1', target: '/chat', data: { id: 1 }, url: `${ORIGIN}/chat` })); + expect(open.postMessage).toHaveBeenCalledWith({ + type: 'puter-notification-click', + notification: { uid: 'n1', target: '/chat', data: { id: 1 } }, + }); + expect(open.focus).toHaveBeenCalled(); + expect(worker.scope.clients.openWindow).not.toHaveBeenCalled(); + }); + + it('opens the app with the tap in the URL and keeps it for the page', async () => { + const worker = loadWorker(); + await worker.fire('notificationclick', tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` })); + expect(worker.scope.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`); + expect(worker.stores.clicks.get('n1')).toMatchObject({ uid: 'n1', target: '/chat' }); + }); + + it('ignores the app`s own notifications', async () => { + const worker = loadWorker(); + await worker.fire('notificationclick', { notification: { data: { mine: true }, close: vi.fn() } }); + expect(worker.scope.clients.openWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/puter-js/webpack.config.js b/src/puter-js/webpack.config.js index e0312d1e2..1bb82d291 100644 --- a/src/puter-js/webpack.config.js +++ b/src/puter-js/webpack.config.js @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import path from 'node:path'; import webpack from 'webpack'; @@ -10,6 +11,23 @@ import webpack from 'webpack'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// `push-sw.js` is a standalone classic script that apps import into their own +// service worker; it ships next to the bundle as-is. +const PUSH_WORKER = path.resolve(__dirname, 'src/push-sw.js'); +class EmitPushWorker { + apply(compiler) { + compiler.hooks.thisCompilation.tap('EmitPushWorker', (compilation) => { + compilation.fileDependencies.add(PUSH_WORKER); + compilation.hooks.processAssets.tap( + { name: 'EmitPushWorker', stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL }, + () => { + compilation.emitAsset('push-sw.js', new webpack.sources.RawSource(readFileSync(PUSH_WORKER, 'utf8'))); + }, + ); + }); + } +} + export default (env = {}) => ({ entry: './src/index.js', output: { @@ -58,6 +76,7 @@ export default (env = {}) => ({ }, } : {}), plugins: [ + new EmitPushWorker(), new webpack.DefinePlugin({ 'globalThis.PUTER_ORIGIN_ENV': JSON.stringify(process.env.PUTER_ORIGIN || 'https://puter.com'), 'globalThis.PUTER_API_ORIGIN_ENV': JSON.stringify(process.env.PUTER_API_ORIGIN || 'https://api.puter.com'),