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');
+ });
+});