From 1b6de39a13d23ce30bc9a6091dedf9b90bb9fc11 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 28 Oct 2026 01:17:00 -0700 Subject: [PATCH] feat(notifications): enable() asks through Puter's prompt after a revoke When the user turned an app's notifications off in Puter, the browser's permission on the app's origin no longer re-enables them. enable() asks again through Puter's permission prompt and retries once. --- src/docs/src/Notifications/enable.md | 2 +- .../src/modules/notifications/enable.js | 12 +++- .../src/modules/notifications/webPush.test.js | 64 +++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/docs/src/Notifications/enable.md b/src/docs/src/Notifications/enable.md index e0a521d59..4127a9ad6 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, which the user can revoke in Puter's settings. +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. Inside the Puter desktop it does what [`enablePush()`](/Notifications/enablePush/) does. diff --git a/src/puter-js/src/modules/notifications/enable.js b/src/puter-js/src/modules/notifications/enable.js index 0b80b24a6..e7d4259b2 100644 --- a/src/puter-js/src/modules/notifications/enable.js +++ b/src/puter-js/src/modules/notifications/enable.js @@ -27,8 +27,14 @@ export async function enable(options = {}) { if ((await asking) !== 'granted') return false; if (needsSignIn(puter)) await signInVisitor(puter); - await syncWebPush(puter, { - worker: workerFor(puter.env, options.serviceWorker), - }); + 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 }); + } return true; } diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js index 737ebe714..947b93106 100644 --- a/src/puter-js/src/modules/notifications/webPush.test.js +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -251,3 +251,67 @@ describe('onPush', () => { expect(module.onNotification).toHaveBeenCalledTimes(1); }); }); + +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, + onAuthStateChanged: () => () => {}, + }); + + it('asks the browser before anything is awaited', async () => { + const nav = makeNav({ existing: makeSubscription() }); + withBrowser(nav, 'default'); + mockRequest.mockImplementation(async (_p, method, route) => + route === '/push/web/key' + ? { publicKey: KEY } + : { status: 'active', device: { uid: 'd1' } }, + ); + const pending = makeModule().enable(); + expect(Notification.requestPermission).toHaveBeenCalledTimes(1); + await expect(pending).resolves.toBe(true); + }); + + it('asks through Puter`s prompt when the user turned the app off there', 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 module = makeModule(); + module.requestPermission = vi.fn(async () => { + revoked = false; + return true; + }); + 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); + }); +});