feat(gui): register this browser for pushes again where it already allows them

On desktop and dashboard boot, and when an app that holds
notifications:push is launched, a browser whose notification permission
is already granted registers again without a click, unless the user
turned push off in this browser.
This commit is contained in:
Juan Castro committed 2026-10-27 16:27:23 -04:00
1 parent f880544ebe
commit feb0937b6d
5 files changed
+210 -2

No files matched your search

@@ -30,6 +30,7 @@ import {
tapRoute,
} from '../../helpers/notificationTap.js';
import { showLocalBanner, startPushWorker } from '../../helpers/pushWorker.js';
import { syncBrowserPush } from '../../helpers/pushSync.js';
import { get_app_by_uid } from '../../helpers/getAppByUid.js';
import launch_app from '../../helpers/launchApp.js';
import {
@@ -800,7 +801,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
});
setPushTapHandler(actOnPush);
startPushWorker();
startPushWorker().then(() => syncBrowserPush());
render();
void refresh();
+2 -1
View File
@@ -54,6 +54,7 @@ import {
storedTapHandler,
} from '../helpers/notificationTap.js';
import { showLocalBanner, startPushWorker } from '../helpers/pushWorker.js';
import { syncBrowserPush } from '../helpers/pushSync.js';
import { get_app_by_uid } from '../helpers/getAppByUid.js';
import { httpsIconFor, notificationTarget, senderNameFor } from './Dashboard/notificationCenter.js';
import apply_item_added_to_containers from '../helpers/applyItemAddedToContainers.js';
@@ -270,7 +271,7 @@ async function UIDesktop (options) {
});
},
}));
startPushWorker();
startPushWorker().then(() => syncBrowserPush());
/**
* Raise one notification, whichever path it arrived on. `replay` names
+5
View File
@@ -27,6 +27,7 @@ import { expand_home_path } from './expandHomePath.js';
import { confirmUrlFileAccess } from './confirmUrlFileAccess.js';
import { mintGodmodeToken, reusableGodmodeToken, trackGodmodeToken } from './godmodeTokens.js';
import { withoutNotificationClick } from './notificationTap.js';
import { syncBrowserPush } from './pushSync.js';
const normalizePrivateAccessDecision = (privateAccess) => {
if ( !privateAccess || typeof privateAccess !== 'object' ) {
@@ -758,6 +759,10 @@ const launch_app = async (options) => {
$(el_win).show();
}
if ( ! window.embedded_in_popup ) {
void syncBrowserPush({ appUid: launch_app_uid });
}
// send post request to /rao to record app open
if ( options.name !== 'explorer' ) {
// add the app to the beginning of the array
+55
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// Registers this browser again without a click where the browser already allows it.
import { appHoldsPermissions } from './holdsPermissions.js';
import { listPushApps } from './notificationApi.js';
import {
browserPushOptedOut,
browserPushState,
enableBrowserPush,
PUSH_PERMISSION,
pushClientWithin,
} from './pushClient.js';
/** @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 } = {}, {
NotificationApi = globalThis.Notification,
userUid = globalThis.user?.uuid,
storage = globalThis.localStorage,
holdsPush = async () => (appUid
? appHoldsPermissions([PUSH_PERMISSION], appUid)
: (await listPushApps()).length > 0),
} = {}) => {
if ( NotificationApi?.permission !== 'granted' || ! userUid ) return 'skipped';
if ( browserPushOptedOut(userUid, storage) ) return 'opted_out';
const client = await pushClientWithin();
const state = await browserPushState(client);
if ( state !== 'off' ) return state;
try {
if ( ! await holdsPush() ) return 'no_app';
} catch ( e ) {
console.warn('[push] could not tell whether an app may send pushes', e);
return 'failed';
}
return await enableBrowserPush(client, userUid) ? 'on' : 'failed';
};
+146
View File
@@ -0,0 +1,146 @@
// @vitest-environment jsdom
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
window.api_origin = 'https://api.puter.test';
window.auth_token = 'tok-1';
window.gui_params = { pushEnabled: true };
const fakeClient = (status) => ({
enable: vi.fn(async () => ({ deviceUid: 'dev-new' })),
disable: vi.fn(async () => {}),
status: vi.fn(async () => status),
});
const granted = { permission: 'granted' };
const reply = (status, data = {}) => new Response(JSON.stringify(data), { status });
/** Answers each `METHOD path` from `routes`; anything else is a 404. */
const serve = (routes) => {
globalThis.fetch = vi.fn(async (url, init = {}) => {
const handler = routes[`${init.method ?? 'GET'} ${new URL(url).pathname}`];
if ( ! handler ) return reply(404, { code: 'not_found' });
return typeof handler === 'function' ? handler(init) : reply(200, handler);
});
};
let syncBrowserPush;
let registerPushClient;
beforeEach(async () => {
vi.resetModules();
({ syncBrowserPush } = await import('./pushSync.js'));
({ registerPushClient } = await import('./pushClient.js'));
localStorage.clear();
serve({});
});
const off = () => {
const client = fakeClient({ permission: 'granted', registered: false });
registerPushClient(client);
return client;
};
describe('syncBrowserPush', () => {
it('registers this browser again when an app may send pushes', async () => {
const client = off();
const holdsPush = vi.fn(async () => true);
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush })).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');
expect(client.enable).not.toHaveBeenCalled();
});
it.each([
['the browser has not granted notifications', { NotificationApi: { permission: 'default' }, userUid: 'u-1' }],
['nobody is signed in', { NotificationApi: granted, userUid: undefined }],
])('does nothing when %s', async (_label, deps) => {
const client = off();
await expect(syncBrowserPush({}, { ...deps, holdsPush: async () => true })).resolves.toBe('skipped');
expect(client.status).not.toHaveBeenCalled();
expect(client.enable).not.toHaveBeenCalled();
});
it('leaves a browser the user turned off alone', async () => {
const client = off();
localStorage.setItem('puter.push.browser-off:u-1', '1');
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true })).resolves.toBe('opted_out');
expect(client.status).not.toHaveBeenCalled();
expect(client.enable).not.toHaveBeenCalled();
});
it('keeps another user\'s opt-out to that user', async () => {
const client = off();
localStorage.setItem('puter.push.browser-off:u-2', '1');
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true })).resolves.toBe('on');
expect(client.enable).toHaveBeenCalledTimes(1);
});
it.each([
['on', { permission: 'granted', registered: true, deviceUid: 'dev-1' }],
['blocked', { permission: 'denied', registered: false }],
])('reports %s and does not register again', async (state, status) => {
const client = fakeClient(status);
registerPushClient(client);
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true })).resolves.toBe(state);
expect(client.enable).not.toHaveBeenCalled();
});
it('reports unavailable without a client', async () => {
window.gui_params = { pushEnabled: false };
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true })).resolves.toBe('unavailable');
window.gui_params = { pushEnabled: true };
});
it('reports failed when registering fails or the app check fails', async () => {
const client = off();
client.enable.mockRejectedValue(new Error('no_worker'));
const quiet = [vi.spyOn(console, 'debug'), vi.spyOn(console, 'warn')].map(spy => spy.mockImplementation(() => {}));
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => true })).resolves.toBe('failed');
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1', holdsPush: async () => {
throw new Error('down');
} })).resolves.toBe('failed');
for ( const spy of quiet ) spy.mockRestore();
});
it('asks the server about the launched app', async () => {
const client = off();
const check = vi.fn((init) => reply(200, { permissions: Object.fromEntries(JSON.parse(init.body).permissions.map(p => [p, true])) }));
serve({ 'POST /auth/check-permissions': check });
await expect(syncBrowserPush({ appUid: 'app-1' }, { NotificationApi: granted, userUid: 'u-1' })).resolves.toBe('on');
expect(JSON.parse(check.mock.calls[0][0].body)).toEqual({ permissions: ['notifications:push'], app_uid: 'app-1' });
expect(client.enable).toHaveBeenCalledTimes(1);
});
it('asks the server for the apps that may send pushes when no app is named', async () => {
const client = off();
serve({ 'GET /push/apps': { apps: [{ uid: 'app-1', name: 'chat' }] } });
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1' })).resolves.toBe('on');
expect(client.enable).toHaveBeenCalledTimes(1);
serve({ 'GET /push/apps': { apps: [] } });
await expect(syncBrowserPush({}, { NotificationApi: granted, userUid: 'u-1' })).resolves.toBe('no_app');
});
});