diff --git a/src/docs/src/Notifications/enable.md b/src/docs/src/Notifications/enable.md index 4127a9ad6..3be6c3d0c 100644 --- a/src/docs/src/Notifications/enable.md +++ b/src/docs/src/Notifications/enable.md @@ -6,7 +6,7 @@ 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. If the user later turns your app off in Puter's settings, that stands: `enable()` then asks again through Puter's permission prompt rather than relying on the browser's permission. +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 the device is registered once your page hands the confirmation back. Allowing it also lets your app's notifications go out as pushes. If the user later turns your app off in Puter's settings, that stands and the call rejects with `push_revoked`. To ask again, call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call `enable()` again. Inside the Puter desktop it does what [`enablePush()`](/Notifications/enablePush/) does. @@ -24,6 +24,6 @@ puter.notifications.enable(options) - `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. +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_revoked` when the user turned your app off in Puter's settings, `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 index c5451dfa6..16a6d7fc9 100644 --- a/src/docs/src/Notifications/onPush.md +++ b/src/docs/src/Notifications/onPush.md @@ -37,7 +37,7 @@ Returning nothing shows the notification's own `title` and `text`. In the page, #### `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. +- `onError` (Function): called when the handler throws, the live listener stops, or keeping the subscription current fails. Without it, errors go to the console. A server without a push service (`push_unavailable`) and an app the user turned off in Puter (`push_revoked`) are not reported here, since [`enable()`](/Notifications/enable/) rejects with them when called. ## Handler limits diff --git a/src/gui/src/helpers/pushWorker.js b/src/gui/src/helpers/pushWorker.js index 1169b1f5f..034167293 100644 --- a/src/gui/src/helpers/pushWorker.js +++ b/src/gui/src/helpers/pushWorker.js @@ -64,6 +64,23 @@ export const shouldShowLocalBanner = ({ replay = false, acknowledged = false, no const nonEmpty = (value) => (typeof value === 'string' && value.trim() !== '' ? value : undefined); +const HOLDS_PUSH_TTL_MS = 60 * 1000; +const HOLDS_PUSH_MAX_ENTRIES = 50; +/** @type {Map }>} */ +const holdsPushCache = new Map(); + +/** @type {(appUid: string, holdsPush: (appUid: string) => Promise) => Promise} One check per app a minute: a busy app sends many messages while the tab is hidden. */ +const appMayPush = (appUid, holdsPush) => { + const now = Date.now(); + const hit = holdsPushCache.get(appUid); + if ( hit && now - hit.at < HOLDS_PUSH_TTL_MS ) return hit.held; + const held = holdsPush(appUid); + holdsPushCache.delete(appUid); + holdsPushCache.set(appUid, { at: now, held }); + while ( holdsPushCache.size > HOLDS_PUSH_MAX_ENTRIES ) holdsPushCache.delete(holdsPushCache.keys().next().value); + return held; +}; + /** * @param {string} uid * @param {Object} notification @@ -126,7 +143,7 @@ export const showLocalBanner = async ({ uid, notification, appUid = null }, { re if ( ! await registration.pushManager?.getSubscription() ) return false; if ( notification.type === APP_MESSAGE_TYPE ) { const app = appUid ?? notification.app?.uid; - if ( ! app || ! await holdsPush(app) ) return false; + if ( ! app || ! await appMayPush(app, holdsPush) ) return false; } worker.postMessage({ type: WORKER_SHOW_MESSAGE, puter: pushMessageFor(uid, notification, appUid) }); return true; diff --git a/src/gui/src/helpers/pushWorker.test.js b/src/gui/src/helpers/pushWorker.test.js index d4c3b1ce1..2cc9936cb 100644 --- a/src/gui/src/helpers/pushWorker.test.js +++ b/src/gui/src/helpers/pushWorker.test.js @@ -175,12 +175,32 @@ describe('showLocalBanner', () => { }); it('shows an app`s message only when the app may push', async () => { - const message = { type: 'app.message', title: 'Hi', app: { uid: 'app-1' } }; + const message = (uid) => ({ type: 'app.message', title: 'Hi', app: { uid } }); const denied = env({ holds: false }); - expect(await showLocalBanner({ uid: 'n1', notification: message, appUid: 'app-1' }, {}, denied)).toBe(false); + expect(await showLocalBanner({ uid: 'n1', notification: message('app-1'), appUid: 'app-1' }, {}, denied)).toBe(false); expect(denied.holdsPush).toHaveBeenCalledWith('app-1'); const allowed = env(); - expect(await showLocalBanner({ uid: 'n1', notification: message, appUid: 'app-1' }, {}, allowed)).toBe(true); + expect(await showLocalBanner({ uid: 'n1', notification: message('app-2'), appUid: 'app-2' }, {}, allowed)).toBe(true); + }); + + it('asks once a minute whether an app may push', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + vi.setSystemTime(1_000); + const e = env(); + const message = { type: 'app.message', title: 'Hi', app: { uid: 'app-busy' } }; + const show = (uid) => showLocalBanner({ uid, notification: message, appUid: 'app-busy' }, {}, e); + expect(await Promise.all([show('n1'), show('n2')])).toEqual([true, true]); + expect(await show('n3')).toBe(true); + expect(e.holdsPush).toHaveBeenCalledTimes(1); + expect(e.active.postMessage).toHaveBeenCalledTimes(3); + + vi.setSystemTime(1_000 + 60 * 1000); + expect(await show('n4')).toBe(true); + expect(e.holdsPush).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } }); it('hands nothing over where push is off for the deployment', async () => { diff --git a/src/puter-js/src/modules/notifications/enable.js b/src/puter-js/src/modules/notifications/enable.js index e7d4259b2..7709e39c8 100644 --- a/src/puter-js/src/modules/notifications/enable.js +++ b/src/puter-js/src/modules/notifications/enable.js @@ -8,7 +8,9 @@ import { syncWebPush, webPushSupported, workerFor } from './lib/webPush.js'; * 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. + * and confirms the subscription with Puter. It rejects with `push_revoked` when + * the user turned the app off in Puter; `requestPermission()` from a click + * undoes that. * * @this {import('./index.js').NotificationsModule} * @param {{ serviceWorker?: string }} [options] @@ -28,13 +30,6 @@ export async function enable(options = {}) { if (needsSignIn(puter)) await signInVisitor(puter); const worker = workerFor(puter.env, options.serviceWorker); - try { - await syncWebPush(puter, { worker }); - } catch (error) { - // The user turned this app off in Puter; only Puter's prompt undoes it. - if (error?.code !== 'push_revoked') throw error; - if (!(await this.requestPermission())) return false; - await syncWebPush(puter, { worker }); - } + await syncWebPush(puter, { worker }); return true; } diff --git a/src/puter-js/src/modules/notifications/onPush.js b/src/puter-js/src/modules/notifications/onPush.js index 12233a51d..b1a86bdf4 100644 --- a/src/puter-js/src/modules/notifications/onPush.js +++ b/src/puter-js/src/modules/notifications/onPush.js @@ -26,6 +26,9 @@ export const shownFrom = (notification, result) => { return shown; }; +/** Sync failures that are the server's or the user's standing choice, not a fault. */ +const QUIET_SYNC_ERRORS = new Set(['push_unavailable', 'push_revoked']); + const report = (onError, error) => { if (onError) onError(error); else console.error('[puter.notifications] push handler failed', error); @@ -76,6 +79,8 @@ export async function onPush(key, handler, options = {}) { try { await syncWebPush(puter, { worker }); } catch (error) { + // Nothing a page load can do about these; `enable()` reports them from a click. + if (QUIET_SYNC_ERRORS.has(error?.code)) return; report(options.onError, error); } }; diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js index 947b93106..6a11c8f35 100644 --- a/src/puter-js/src/modules/notifications/webPush.test.js +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -22,10 +22,10 @@ const { shownFrom } = await import('./onPush.js'); const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url'); const makeSubscription = (key = fromBase64Url(KEY)) => ({ - endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + endpoint: 'https://push.example/send/abc', options: { applicationServerKey: key.buffer }, toJSON: () => ({ - endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + endpoint: 'https://push.example/send/abc', expirationTime: null, keys: { p256dh: 'p', auth: 'a' }, }), @@ -58,11 +58,30 @@ const makeNav = ({ existing = null } = {}) => { const puter = { env: 'web', authToken: 't', APIOrigin: 'https://api.test' }; const worker = workerFor('web'); +const withBrowser = (nav, permission = 'granted') => { + globalThis.PushManager = function PushManager() {}; + globalThis.Notification = { + permission, + requestPermission: vi.fn(async () => 'granted'), + }; + Object.defineProperty(globalThis.navigator, 'serviceWorker', { + value: nav.serviceWorker, + configurable: true, + }); +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + beforeEach(() => { mockRequest.mockReset(); store.clear(); }); +afterEach(() => { + delete globalThis.PushManager; + delete globalThis.Notification; +}); + describe('syncWebPush', () => { it('refreshes a subscription the server already has', async () => { const nav = makeNav({ existing: makeSubscription() }); @@ -86,7 +105,7 @@ describe('syncWebPush', () => { '/push/web/subscriptions', { subscription: { - endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + endpoint: 'https://push.example/send/abc', keys: { p256dh: 'p', auth: 'a' }, }, }, @@ -250,26 +269,41 @@ describe('onPush', () => { await module.onPush('mail', () => {}); expect(module.onNotification).toHaveBeenCalledTimes(1); }); + + it('keeps quiet when the server has no push service or the app is turned off', async () => { + withBrowser(makeNav()); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { PuterJSError } = await import('../../lib/PuterJSError.js'); + + mockRequest.mockResolvedValue({}); + const onError = vi.fn(); + await makeModule().onPush('chat', () => {}, { onError }); + await flush(); + expect(mockRequest).toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') return { publicKey: KEY }; + throw new PuterJSError('off', 'push_revoked'); + }); + await makeModule().onPush('chat', () => {}); + await flush(); + expect(error).not.toHaveBeenCalled(); + + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') return { publicKey: KEY }; + throw new PuterJSError('down', 'internal_error'); + }); + await makeModule().onPush('chat', () => {}, { onError }); + await flush(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'internal_error' }), + ); + error.mockRestore(); + }); }); describe('enable', () => { - const withBrowser = (nav, permission = 'granted') => { - globalThis.PushManager = function PushManager() {}; - globalThis.Notification = { - permission, - requestPermission: vi.fn(async () => 'granted'), - }; - Object.defineProperty(globalThis.navigator, 'serviceWorker', { - value: nav.serviceWorker, - configurable: true, - }); - }; - - afterEach(() => { - delete globalThis.PushManager; - delete globalThis.Notification; - }); - const makeModule = () => new NotificationsModule({ ...puter, @@ -289,29 +323,21 @@ describe('enable', () => { await expect(pending).resolves.toBe(true); }); - it('asks through Puter`s prompt when the user turned the app off there', async () => { + it('rejects with push_revoked when the user turned the app off in Puter, and opens no prompt', async () => { const nav = makeNav({ existing: makeSubscription() }); withBrowser(nav); - let revoked = true; mockRequest.mockImplementation(async (_p, method, route) => { if (route === '/push/web/key') return { publicKey: KEY }; - if (revoked) { - const { PuterJSError } = - await import('../../lib/PuterJSError.js'); - throw new PuterJSError('off', 'push_revoked'); - } - return { status: 'active', device: { uid: 'd1' } }; + const { PuterJSError } = await import('../../lib/PuterJSError.js'); + throw new PuterJSError('off', 'push_revoked'); }); const module = makeModule(); - module.requestPermission = vi.fn(async () => { - revoked = false; - return true; + module.requestPermission = vi.fn(async () => true); + await expect(module.enable()).rejects.toMatchObject({ + code: 'push_revoked', }); - await expect(module.enable()).resolves.toBe(true); - expect(module.requestPermission).toHaveBeenCalledTimes(1); - - revoked = true; - module.requestPermission = vi.fn(async () => false); - await expect(module.enable()).resolves.toBe(false); + expect(module.requestPermission).not.toHaveBeenCalled(); + expect(Notification.requestPermission).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(2); }); });