Ask for notification permission from Puter's dialog when the click has expired (#4514)

On a website, puter.notifications.enable() threw user_gesture_required
without prompting once the calling click's activation was gone: after
puter.auth.signIn() or requestPermission() opened a popup in the same
click, after a few seconds of awaited work, or when called on load. Apps
that ignored the error showed nothing.

It now shows Puter's consent dialog in that case and asks the browser
from its Continue click; Cancel resolves false. Calls made while an ask
is open share it. PuterDialog gets an onContinue option and a show()
that launch() also uses. Inside Puter's own desktop page the call still
throws, since it always runs from a click.

The push guide also notes that Chrome stops showing the prompt after
three dismissals and that its quieter messaging shows a bell icon.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-11-05 23:10:06 -07:00
1 parent ef185c04ca
commit 9d22f39928
6 files changed
+273 -25

No files matched your search

+4 -3
View File
@@ -4,7 +4,9 @@ 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 for the browser's permission before anything else, because browsers only show that prompt from a user gesture.
Turns on push notifications for your app in the browser the user is in. Call it from a click, before any other `await`. It asks for the browser's permission before anything else, because browsers only show that prompt from a user gesture.
On a website, when the call isn't made from a click, or comes after a sign-in window, another popup, or a few seconds of other work in the same click, it first shows a Puter dialog. The browser's prompt opens when the user clicks **Continue** there. If they click **Cancel**, the call resolves `false`.
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, which shows once as "Notifications are on". Turning pushes on this way also allows pushes from your app, so the user answers only the browser's prompt.
@@ -34,13 +36,12 @@ puter.notifications.enable(options)
- `serviceWorker` (String): The path of the worker that imports `push-sw.js`. Defaults to `/puter-sw.js`. Pass the same path to [`onPush()`](/Notifications/onPush/), [`disable()`](/Notifications/disable/) and [`status()`](/Notifications/status/). Only used on a website.
## 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, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
A `Promise` that resolves to `true` when this browser receives pushes for your app, and `false` when the user didn't allow notifications, canceled Puter's dialog, or the browser can't receive pushes, such as a private window or an iPhone page that isn't opened from the Home Screen. [Where pushes work](/Notifications/push/#where-pushes-work) lists what each browser needs.
It rejects with an error carrying `message` and `code`:
| `code` | Meaning |
| --- | --- |
| `user_gesture_required` | The browser hasn't asked for notification permission yet, and the call wasn't made from a user gesture, such as a click. |
| `push_unavailable` | This server has no push notification service, as on most self-hosted servers. |
| `push_disabled` | This server has push notifications turned off. |
| `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click, then call [`enable()`](/Notifications/enable/) again. |
+2 -1
View File
@@ -86,7 +86,7 @@ The handler runs in the page as each notification arrives, and in your service w
### 3. Let the user turn pushes on
Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture.
Call [`enable()`](/Notifications/enable/) from a click. Browsers only show their permission prompt from a user gesture. Called without one, it shows a Puter dialog first, and the browser's prompt opens from its **Continue** button.
```js
document.getElementById('enable').addEventListener('click', async () => {
@@ -331,6 +331,7 @@ The browser can be allowed to show notifications while the operating system hide
| What you see | Likely cause |
| --- | --- |
| [`enable()`](/Notifications/enable/) resolves `false` | The user blocked notifications for your site, the page is in a private window, the browser has no Web Push, or on iOS the page isn't opened from the Home Screen. The user unblocks your site in the browser's site settings. |
| The browser's prompt doesn't show | Chrome stops showing it for a week after the user closes it three times. With quieter messaging on, Chrome shows a bell icon in the address bar instead. The user can allow notifications in the browser's site settings. |
| It rejects with `push_worker_unavailable` | `/puter-sw.js` isn't on your origin, returns a `404`, isn't served as JavaScript, is redirected, or throws when it loads. Open its URL in a tab to check. |
| It rejects with `push_subscribe_failed` | The browser couldn't reach its push service. In Brave, turn on Google services for push messaging. |
| It rejects with `push_revoked` | The user turned your app off in Puter's settings. Call [`requestPermission()`](/Notifications/requestPermission/) from a click first. |
+24 -2
View File
@@ -24,6 +24,8 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
* navigates (and hijacks) a popup another pending flow is waiting on.
* @param {Function} [options.onLaunch] - Called with the opened popup
* window (or null if the browser blocked it) right after launch.
* @param {Function} [options.onContinue] - Runs inside the Continue click
* in place of a popup, for a browser API that needs the click.
* @param {Function} [options.onCancel] - Called when the user dismisses
* the dialog without completing authentication.
*/
@@ -510,13 +512,18 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
return `${puter.defaultGUIOrigin}/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`;
}
/** Launcher mode: the caller owns the outcome, not the implicit-auth flow. */
#isLauncher () {
return !! (this.options.popupURL || this.options.onContinue);
}
// Optional: Handle dialog cancellation as rejection
cancelListener = () => {
this.close();
window.removeEventListener('message', this.messageListener);
// Launcher mode: the caller owns the auth promise and any state.
if ( this.options.popupURL ) {
if ( this.#isLauncher() ) {
if ( typeof this.options.onCancel === 'function' ) {
this.options.onCancel();
}
@@ -542,6 +549,11 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
// Wire the "Continue" button to open the auth popup. Opening here is
// safe from being popup-blocked because it happens inside a click.
this.shadowRoot.querySelector('#launch-auth-popup')?.addEventListener('click', () => {
if ( this.options.onContinue ) {
this.options.onContinue();
this.close();
return;
}
const popup = this.#openPopup();
// Pinned as the expected event.source in messageListener.
this.authPopup = popup;
@@ -558,7 +570,7 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
// The implicit-auth flow listens for the token message from the popup.
// In launcher mode the caller registers its own message handler.
if ( ! this.options.popupURL ) {
if ( ! this.#isLauncher() ) {
window.addEventListener('message', this.messageListener);
}
@@ -621,6 +633,16 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b
return null;
}
}
return PuterDialog.show(options);
}
/**
* Shows a launcher-mode consent dialog; throws where modals are blocked.
*
* @param {Object} options - Launcher-mode options, as for the constructor.
* @returns {PuterDialog}
*/
static show (options) {
const dialog = new PuterDialog(() => {}, () => {}, options);
document.body.appendChild(dialog);
try {
@@ -0,0 +1,179 @@
// @vitest-environment jsdom
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',
SERVER_KEY_ENTRY: 'puter:server-key',
SERVER_KEYS_ENTRY: 'puter:server-keys',
SYNCED_PREFIX: 'puter:synced:',
SUBSCRIPTION_CHANGED_PREFIX: 'puter:subscription-changed:',
TAP_BASE_PREFIX: 'puter:tap-base:',
CLICK_KEEP_MS: 5 * 60 * 1000,
takeClick: async () => null,
forgetOldClicks: async () => {},
forgetPushState: async () => store.clear(),
idbGet: async (s, k) => store.get(`${s}:${k}`),
idbPut: async (s, k, v) => void store.set(`${s}:${k}`, v),
idbDelete: async (s, k) => void store.delete(`${s}:${k}`),
hasOptIn: async (uuid) => store.get(`opt-in:${uuid}`) === true,
setOptIn: async (uuid, on) => void store.set(`opt-in:${uuid}`, on),
}));
const { NotificationsModule } = await import('./index.js');
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
HTMLDialogElement.prototype.close = function () {
this.open = false;
};
const KEY = Buffer.from([4, ...Array(64).fill(7)]).toString('base64url');
const subscription = {
endpoint: 'https://push.example/send/abc',
options: { applicationServerKey: null },
toJSON: () => ({
endpoint: 'https://push.example/send/abc',
keys: { p256dh: 'p', auth: 'a' },
}),
};
const makeModule = () =>
new NotificationsModule({
env: 'web',
authToken: 't',
APIOrigin: 'https://api.test',
whoami: { uuid: 'u-1' },
onAuthStateChanged: () => () => {},
});
const dialog = () => document.querySelector('puter-dialog');
const click = (selector) =>
dialog().shadowRoot.querySelector(selector).click();
const announced = () =>
mockRequest.mock.calls.filter(
([, method, route]) =>
method === 'POST' && route === '/push/web/subscriptions',
);
beforeEach(() => {
mockRequest.mockReset();
mockRequest.mockImplementation(async (_p, _method, route) =>
route === '/push/web/key'
? { publicKey: KEY }
: { status: 'active', device: { uid: 'd1' } },
);
store.clear();
document.body.innerHTML = '';
globalThis.PushManager = function PushManager() {};
globalThis.Notification = {
permission: 'default',
requestPermission: vi.fn(async () => 'granted'),
};
const registration = {
active: { scriptURL: new URL('/puter-sw.js', location.href).href },
pushManager: {
getSubscription: async () => null,
subscribe: vi.fn(async () => subscription),
},
};
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
register: vi.fn(async () => registration),
getRegistration: vi.fn(async () => registration),
addEventListener: () => {},
removeEventListener: () => {},
},
});
// The click that called enable() is no longer active.
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive: false },
});
});
afterEach(() => {
delete navigator.userActivation;
delete navigator.serviceWorker;
delete globalThis.PushManager;
delete globalThis.Notification;
});
describe('enable() on a website without the click', () => {
it('asks the browser from the Continue click of Puter`s dialog, then turns pushes on', async () => {
const enabling = makeModule().enable();
expect(dialog()).not.toBeNull();
expect(Notification.requestPermission).not.toHaveBeenCalled();
click('#launch-auth-popup');
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
await expect(enabling).resolves.toBe(true);
expect(dialog()).toBeNull();
expect(announced()).toHaveLength(1);
expect(store.get('opt-in:u-1')).toBe(true);
});
it('resolves false without asking the browser when the user cancels', async () => {
const enabling = makeModule().enable();
click('#launch-auth-popup-cancel');
await expect(enabling).resolves.toBe(false);
expect(Notification.requestPermission).not.toHaveBeenCalled();
expect(mockRequest).not.toHaveBeenCalled();
expect(dialog()).toBeNull();
});
it('resolves false when the browser refuses from the dialog', async () => {
Notification.requestPermission.mockResolvedValue('denied');
const enabling = makeModule().enable();
click('#launch-auth-popup');
await expect(enabling).resolves.toBe(false);
expect(mockRequest).not.toHaveBeenCalled();
});
it('shows one dialog for calls made while it is open', async () => {
const module = makeModule();
const first = module.enable();
const second = module.enable();
expect(document.querySelectorAll('puter-dialog')).toHaveLength(1);
click('#launch-auth-popup');
await expect(Promise.all([first, second])).resolves.toEqual([
true,
true,
]);
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
});
it('asks right away with no dialog while the click is still active', async () => {
navigator.userActivation.isActive = true;
const enabling = makeModule().enable();
expect(Notification.requestPermission).toHaveBeenCalledTimes(1);
expect(dialog()).toBeNull();
await expect(enabling).resolves.toBe(true);
});
it('rejects user_gesture_required where the page can`t show a dialog', async () => {
const showModal = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = () => {
throw new Error('InvalidStateError');
};
try {
await expect(makeModule().enable()).rejects.toMatchObject({
code: 'user_gesture_required',
});
expect(dialog()).toBeNull();
expect(Notification.requestPermission).not.toHaveBeenCalled();
} finally {
HTMLDialogElement.prototype.showModal = showModal;
}
});
});
@@ -1,11 +1,70 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import PuterDialog from '../PuterDialog.js';
import { needsSignIn, signInVisitor } from '../events/lib/signIn.js';
import { webPushSupported, workerFor } from './lib/webPush.js';
/** The browser's answer still being asked for, shared by calls made meanwhile. */
let asking = null;
const gestureRequired = () =>
new PuterJSError(
'enable() opens the browser`s notification prompt, so it must be called from a click',
'user_gesture_required',
);
/** The browser's answer, asked from the Continue click of Puter's dialog. */
const askInDialog = () =>
new Promise((resolve, reject) => {
/** @type {PuterDialog | null} */
let dialog = null;
const answer = (permission) => {
dialog?.remove();
resolve(permission);
};
try {
dialog = PuterDialog.show({
message: 'This website wants to send you notifications.',
onContinue: () => {
Notification.requestPermission().then(answer, () =>
answer('default'),
);
},
onCancel: () => answer('default'),
});
} catch {
reject(gestureRequired());
}
});
/**
* Asks the browser for notification permission; the prompt opens before this
* returns while the caller's click is still active. Without that activation,
* browsers refuse to prompt, so a website asks from Puter's dialog instead.
*
* @param {string} env
* @returns {Promise<NotificationPermission>}
*/
const askBrowser = (env) => {
if (Notification.permission !== 'default')
return Promise.resolve(Notification.permission);
if (asking) return asking;
let ask;
if (globalThis.navigator?.userActivation?.isActive !== false)
ask = Notification.requestPermission();
else if (env === 'web') ask = askInDialog();
else throw gestureRequired();
asking = ask.finally(() => {
asking = null;
});
return asking;
};
/**
* Turns on push notifications for this app in this browser. Call it from a
* click: the browser's prompt is asked before anything else, and without the
* click it rejects with `user_gesture_required` instead of prompting.
* click: the browser's prompt is asked before anything else. On a website
* called without the click's activation, such as after a sign-in window or
* slow work in the same click, it shows Puter's dialog first and asks from its
* Continue click.
*
* 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
@@ -25,21 +84,7 @@ export async function enable(options = {}) {
if (puter.env === 'app') return this.enablePush();
if (!webPushSupported()) return false;
if (
Notification.permission === 'default' &&
globalThis.navigator?.userActivation?.isActive === false
) {
throw new PuterJSError(
'enable() opens the browser`s notification prompt, so it must be called from a click',
'user_gesture_required',
);
}
// 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 ((await askBrowser(puter.env)) !== 'granted') return false;
if (needsSignIn(puter)) await signInVisitor(puter);
await this._enableWebPush(workerFor(puter.env, options.serviceWorker));
@@ -2135,14 +2135,14 @@ describe('enable', () => {
);
});
it('rejects user_gesture_required outside a click, without prompting', async () => {
it('rejects user_gesture_required outside a click in the Puter GUI, without prompting', async () => {
withBrowser(makeNav(), 'default');
Object.defineProperty(globalThis.navigator, 'userActivation', {
value: { isActive: false },
configurable: true,
});
try {
const { module } = makeModule();
const { module } = makeModule({ env: 'gui' });
await expect(module.enable()).rejects.toMatchObject({
code: 'user_gesture_required',
});