fix(gui): one enable-push dialog at a time, only for apps that may send pushes

The desktop and the popup show the enable-push dialog only when the
asking app holds notifications:push. The IPC branch answers a second
call unavailable while a dialog is up, as the feedback dialog does. The
silent registration on boot and app launch runs once at a time. A popup
whose opener cannot be attested answers the enable-push request before
it closes.
This commit is contained in:
Juan Castro committed 2026-10-27 16:48:07 -04:00
1 parent 6c35780d59
commit fe58213bb5
8 files changed
+194 -21

No files matched your search

+2 -2
View File
@@ -4,9 +4,9 @@ description: Offer to turn on push notifications in the browser the user is in.
platforms: [websites, apps]
---
Offers to turn on push notifications in the browser the user is using right now. A push only reaches a browser once the user has turned push notifications on there, and the browser asks for that from a click on the Puter origin. This call provides that click: inside the Puter desktop it shows a dialog with a "Turn on notifications" button, and on a website it opens a small popup on the Puter origin with the same button. Call it from a user gesture such as a click.
Offers to turn on push notifications in the browser the user is using right now. A push only reaches a browser once the user has turned push notifications on there, and the browser asks for that from a click on the Puter origin. This call provides that click. Inside the Puter desktop it shows a dialog with a "Turn on notifications" button, and on a website it opens a small popup on the Puter origin with the same button. Call it from a user gesture such as a click.
When push notifications are already on in this browser, it resolves `true` without showing anything. When the browser has blocked notifications for Puter, or the server has no push service, it resolves `false` without showing anything either.
When push notifications are already on in this browser, it resolves `true` without showing anything. When your app does not hold the [`notifications:push`](/Notifications/requestPermission/) permission, the browser has blocked notifications for Puter, or the server has no push service, it resolves `false` without showing anything either.
Puter also turns push on in the browser by itself where it can. When the user allows [`notifications:push`](/Notifications/requestPermission/) in the permission prompt, that prompt turns push on in the same browser. When the browser already allows notifications from Puter and your app holds the permission, the Puter desktop registers the browser without asking. This call is for the remaining case, a website on its own domain in a browser that has not allowed notifications from Puter yet.
+56
View File
@@ -78,6 +78,9 @@ beforeEach(() => {
window.iframe_for_app_instance = (id) => (id === 'inst-1' ? iframe : null);
window.window_for_app_instance = () => win;
window.is_auth = () => true;
window.api_origin = 'https://api.puter.test';
window.auth_token = 'tok-1';
holds(true);
window.ipc_handlers = {};
globalThis.services = { get: () => ({ get_by_uuid: () => ({ name: 'caller', uuid: 'inst-1' }) }) };
});
@@ -93,6 +96,16 @@ const post = (msg) =>
const settle = () => new Promise((r) => setTimeout(r, 20));
/** What /auth/check-permissions answers for notifications:push. */
const holds = (value) => {
globalThis.fetch = vi.fn(async (url, init) => ({
ok: true,
json: async () => ({ permissions: { 'notifications:push': value } }),
url,
init,
}));
};
describe('the enablePush IPC message', () => {
it('offers the dialog for the calling app and reports the state', async () => {
dialog.mockResolvedValue('on');
@@ -106,6 +119,49 @@ describe('the enablePush IPC message', () => {
);
});
it('asks the server whether this app may send pushes, and shows nothing when it may not', async () => {
holds(false);
dialog.mockResolvedValue('on');
post('enablePush');
await settle();
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ permissions: ['notifications:push'], app_uid: 'app-1' });
expect(dialog).not.toHaveBeenCalled();
expect(posted).toHaveBeenCalledWith(
{ msg: 'pushEnabled', state: 'unavailable', original_msg_id: 'cb-1' },
'*',
);
});
it('answers a second call unavailable while the dialog is up', async () => {
let finish;
dialog.mockImplementation(() => new Promise((resolve) => {
finish = resolve;
}));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
post('enablePush');
await settle();
post('enablePush');
await settle();
expect(dialog).toHaveBeenCalledTimes(1);
expect(posted).toHaveBeenCalledWith(
{ msg: 'pushEnabled', state: 'unavailable', original_msg_id: 'cb-1' },
'*',
);
finish('on');
await settle();
expect(posted).toHaveBeenCalledWith(
{ msg: 'pushEnabled', state: 'on', original_msg_id: 'cb-1' },
'*',
);
post('enablePush');
await settle();
expect(dialog).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
it('reports unavailable when the dialog fails', async () => {
dialog.mockRejectedValue(new Error('boom'));
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
+35 -14
View File
@@ -39,6 +39,8 @@ import UIWindowSignup from './UI/UIWindowSignup.js';
import UINotification from './UI/UINotification.js';
import { renewGodmodeToken } from './helpers/godmodeTokens.js';
import { appHoldsPermissions } from './helpers/holdsPermissions.js';
import { PUSH_PERMISSION } from './helpers/pushClient.js';
import { ipc_target_window } from './helpers/ipcTargetWindow.js';
import { addItemWatcher } from './helpers/watchItems.js';
import { openVerificationGateWindow } from './helpers/verification_gates.js';
@@ -52,6 +54,7 @@ window.ipc_handlers = {};
// handler below): one dialog at a time, and nothing else — reopening is always
// allowed. See feedbackDialogGuard.js.
const feedback_dialog_guard = createFeedbackDialogGuard();
const enable_push_dialog_guard = createFeedbackDialogGuard();
/**
* In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI), and each other, using the postMessage API.
@@ -1501,26 +1504,44 @@ const ipc_listener = async (event, handled) => {
}, '*');
};
try {
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
{
respond('unavailable');
return;
}
} catch ( e ) {
console.error('IPC enablePush: auth gate failed', e);
// One dialog at a time, checked before the auth gate as for the feedback dialog.
if ( ! enable_push_dialog_guard.mayOpen() ) {
console.warn('IPC enablePush: a dialog is already open; ignoring this call');
respond('unavailable');
return;
}
let state = 'unavailable';
enable_push_dialog_guard.markOpened();
try {
state = await UIEnablePushDialog({ app_name });
} catch ( e ) {
console.error('IPC enablePush failed', e);
try {
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
{
respond('unavailable');
return;
}
} catch ( e ) {
console.error('IPC enablePush: auth gate failed', e);
respond('unavailable');
return;
}
// The dialog says this app wants to send pushes, so only an app that may.
if ( ! await appHoldsPermissions([PUSH_PERMISSION], app_uuid) ) {
respond('unavailable');
return;
}
let state = 'unavailable';
try {
state = await UIEnablePushDialog({ app_name });
} catch ( e ) {
console.error('IPC enablePush failed', e);
}
respond(state);
$(target_iframe).get(0)?.focus({ preventScroll: true });
} finally {
enable_push_dialog_guard.markClosed();
}
respond(state);
$(target_iframe).get(0)?.focus({ preventScroll: true });
}
//--------------------------------------------------------
// showFontPicker
+13 -2
View File
@@ -31,8 +31,19 @@ import {
/** @typedef {'skipped'|'opted_out'|'unavailable'|'blocked'|'on'|'off'|'no_app'|'failed'} PushSyncResult */
/** Turns push on here when the browser already grants it, the user has not turned it off here, and an app holds `notifications:push`: the one named by `appUid`, or any app without one. @param {{ appUid?: string }} [scope] @param {{ NotificationApi?: { permission: string }, userUid?: string, storage?: Storage, holdsPush?: () => Promise<boolean> }} [deps] @returns {Promise<PushSyncResult>} */
export const syncBrowserPush = async ({ appUid } = {}, {
/** @type {Promise<PushSyncResult>|null} */
let inFlight = null;
/** Turns push on here when the browser already grants it, the user has not turned it off here, and an app holds `notifications:push`: the one named by `appUid`, or any app without one. One run at a time; a call during a run joins it. @param {{ appUid?: string }} [scope] @param {{ NotificationApi?: { permission: string }, userUid?: string, storage?: Storage, holdsPush?: () => Promise<boolean> }} [deps] @returns {Promise<PushSyncResult>} */
export const syncBrowserPush = (scope, deps) => {
if ( inFlight ) return inFlight;
inFlight = runSync(scope, deps).finally(() => {
inFlight = null;
});
return inFlight;
};
const runSync = async ({ appUid } = {}, {
NotificationApi = globalThis.Notification,
userUid = globalThis.user?.uuid,
storage = globalThis.localStorage,
+21
View File
@@ -66,6 +66,27 @@ describe('syncBrowserPush', () => {
expect(client.enable).toHaveBeenCalledTimes(1);
});
it('runs once at a time: a call during a run joins it', async () => {
const client = off();
let release;
client.enable.mockImplementation(() => new Promise((resolve) => {
release = resolve;
}));
const deps = { NotificationApi: granted, userUid: 'u-1', holdsPush: vi.fn(async () => true) };
const first = syncBrowserPush({}, deps);
const second = syncBrowserPush({ appUid: 'app-1' }, deps);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(second).toBe(first);
expect(client.enable).toHaveBeenCalledTimes(1);
release({ deviceUid: 'dev-new' });
await expect(first).resolves.toBe('on');
client.status.mockResolvedValue({ permission: 'granted', registered: true, deviceUid: 'dev-new' });
await expect(syncBrowserPush({}, deps)).resolves.toBe('on');
expect(client.enable).toHaveBeenCalledTimes(1);
});
it('does nothing when no app may send pushes', async () => {
const client = off();
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => false })).resolves.toBe('no_app');
+19
View File
@@ -102,6 +102,25 @@ describe('the permission popup and browser push', () => {
expect(requestPermission).not.toContain('setTimeout');
});
it('shows the enable-push dialog only for a site whose app may send pushes', () => {
const resolved = enablePush.indexOf('await window.getAppUIDFromOrigin(origin)');
const checked = enablePush.indexOf('await appHoldsPermissions([PUSH_PERMISSION], app_uid)');
const dialog = enablePush.indexOf('await UIEnablePushDialog({ origin })');
expect(resolved).toBeGreaterThan(-1);
expect(checked).toBeGreaterThan(resolved);
expect(dialog).toBeGreaterThan(checked);
});
it('answers an enable-push opener it cannot attest, like a permission prompt', () => {
const unattested = src.slice(
src.indexOf('if (!isAttestedOrigin(window.openerOrigin))'),
src.indexOf("i18n('popup_opener_origin_unsupported')"),
);
expect(unattested).toContain("if (action === 'request-permission')");
expect(unattested).toContain("else if (action === 'enable-push')");
expect(unattested).toContain("state: 'unavailable'");
});
it('answers the enable-push opener after the dialog, then closes', () => {
expect(enablePush).toContain('startPushWorker()');
const dialog = enablePush.indexOf('await UIEnablePushDialog({ origin })');
+21 -3
View File
@@ -58,7 +58,7 @@ import {
wantsFullToken,
} from './util/authmeGrant.js';
import init_device_signals from './helpers/deviceSignals.js';
import { holdsPermissions } from './helpers/holdsPermissions.js';
import { appHoldsPermissions, holdsPermissions } from './helpers/holdsPermissions.js';
import { PUSH_PERMISSION, withEnableDeadline } from './helpers/pushClient.js';
import { startPushWorker } from './helpers/pushWorker.js';
import item_icon from './helpers/itemIcon.js';
@@ -882,8 +882,12 @@ const postAuthActions = async (action) => {
if ( ! origin ) {
throw new Error('no opener origin; not prompting');
}
startPushWorker();
state = await UIEnablePushDialog({ origin });
// The dialog says this site wants to send pushes, so only a site whose app may.
const app_uid = await window.getAppUIDFromOrigin(origin);
if ( app_uid && await appHoldsPermissions([PUSH_PERMISSION], app_uid) ) {
startPushWorker();
state = await UIEnablePushDialog({ origin });
}
} catch (e) {
console.error('enable-push action failed', e);
}
@@ -1612,6 +1616,20 @@ window.initgui = async function (options) {
} catch (e) {
console.error('could not answer the requester', e);
}
} else if (action === 'enable-push') {
try {
window.opener?.postMessage(
{
msg: 'pushEnabled',
state: 'unavailable',
original_msg_id:
window.url_query_params.get('msg_id'),
},
'*',
);
} catch (e) {
console.error('could not answer the requester', e);
}
}
await UIAlert({
message: i18n('popup_opener_origin_unsupported'),
@@ -68,6 +68,7 @@ beforeEach(() => {
value: { postMessage },
});
framedBy('feedback-dialog,enable-push');
document.body.innerHTML = '';
});
afterEach(() => {
@@ -197,6 +198,32 @@ describe('ui.enablePush on a website', () => {
await expect(result).resolves.toBe(false);
}, 5000);
it('resolves false on a cross-origin-isolated page and leaves the popup open', async () => {
Object.defineProperty(window, 'crossOriginIsolated', { configurable: true, value: true });
const ui = makeUI({ env: 'web' });
await expect(ui.enablePush()).resolves.toBe(false);
expect(window.open).toHaveBeenCalledTimes(1);
expect(popup.close).not.toHaveBeenCalled();
Object.defineProperty(window, 'crossOriginIsolated', { configurable: true, value: false });
});
it('opens the popup from the consent dialog\'s Continue and reads its answer', async () => {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: false, isActive: false },
});
const ui = makeUI({ env: 'web' });
const result = ui.enablePush();
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
document.querySelector('puter-dialog').shadowRoot.querySelector('#launch-auth-popup').click();
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
const msg_id = lastUrl().searchParams.get('msg_id');
popupMessage({ msg: 'pushEnabled', state: 'on', original_msg_id: msg_id }, { source: popup });
await expect(result).resolves.toBe(true);
expect(document.querySelector('puter-dialog')).toBeNull();
});
it('collects a click first when there is no user activation', async () => {
Object.defineProperty(navigator, 'userActivation', {
configurable: true,