From feb0937b6d1ae5edbc29d0c4c2d9d11e8f616305 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 27 Oct 2026 16:27:23 -0400 Subject: [PATCH] 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. --- .../UI/Dashboard/UIDashboardNotifications.js | 3 +- src/gui/src/UI/UIDesktop.js | 3 +- src/gui/src/helpers/launchApp.js | 5 + src/gui/src/helpers/pushSync.js | 55 +++++++ src/gui/src/helpers/pushSync.test.js | 146 ++++++++++++++++++ 5 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/gui/src/helpers/pushSync.js create mode 100644 src/gui/src/helpers/pushSync.test.js diff --git a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js index 36ff9a775..561812630 100644 --- a/src/gui/src/UI/Dashboard/UIDashboardNotifications.js +++ b/src/gui/src/UI/Dashboard/UIDashboardNotifications.js @@ -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(); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 7d8552ef2..ca82545c9 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -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 diff --git a/src/gui/src/helpers/launchApp.js b/src/gui/src/helpers/launchApp.js index 2f4dcec58..259cd11cd 100644 --- a/src/gui/src/helpers/launchApp.js +++ b/src/gui/src/helpers/launchApp.js @@ -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 diff --git a/src/gui/src/helpers/pushSync.js b/src/gui/src/helpers/pushSync.js new file mode 100644 index 000000000..74d9ebf1b --- /dev/null +++ b/src/gui/src/helpers/pushSync.js @@ -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 . + */ + +// 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 }} [deps] @returns {Promise} */ +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'; +}; diff --git a/src/gui/src/helpers/pushSync.test.js b/src/gui/src/helpers/pushSync.test.js new file mode 100644 index 000000000..12fa43815 --- /dev/null +++ b/src/gui/src/helpers/pushSync.test.js @@ -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 . + */ +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'); + }); +});