diff --git a/doc/push-client.md b/doc/push-client.md index 30979d45e..e997d6c45 100644 --- a/doc/push-client.md +++ b/doc/push-client.md @@ -15,6 +15,8 @@ Core does not subscribe the browser to a push service, and it has no `/push/*` r `uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it. +When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription. + ## Registering The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this: @@ -44,7 +46,7 @@ interface PushClient { ``` - `enable()` is called from a click, so it may ask for notification permission. It subscribes with the `/sw.js` registration, registers the device with `POST /push/devices` (`platform: 'web'`), and resolves the device `uid` the server returned. It rejects when permission is refused or the device couldn't be registered. -- `disable()` unsubscribes and removes this browser's device with `DELETE /push/devices/:uid`. +- `disable()` unsubscribes and removes this browser's device with `DELETE /push/devices/:uid`. The tab calls it for Turn off, and for Remove on the device marked as this browser; Remove on any other device is a plain `DELETE`. - `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`. After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up. diff --git a/src/gui/src/UI/Dashboard/TabNotifications.js b/src/gui/src/UI/Dashboard/TabNotifications.js index 05f5e60e4..537bbb8c4 100644 --- a/src/gui/src/UI/Dashboard/TabNotifications.js +++ b/src/gui/src/UI/Dashboard/TabNotifications.js @@ -343,8 +343,11 @@ const TabNotifications = { returnFocusTo: this, }); if ( confirmed !== true ) return; + // The client also deletes this browser's token and local record. + const client = device.current ? getPushClient() : null; try { - await removePushDevice(uid); + if ( client ) await client.disable(); + else await removePushDevice(uid); } catch ( e ) { if ( e?.status !== 404 || e?.code === PUSH_UNAVAILABLE ) { console.warn('[push] device removal failed', e); @@ -352,6 +355,10 @@ const TabNotifications = { return; } } + if ( client ) { + await Promise.all([refreshBrowser(), refreshDevices()]); + return; + } devices = devices.filter(d => d.uid !== uid); renderDevices(); if ( device.current ) refreshBrowser(); diff --git a/src/gui/src/UI/Dashboard/TabNotifications.test.js b/src/gui/src/UI/Dashboard/TabNotifications.test.js index 3efbe1a87..4b858022f 100644 --- a/src/gui/src/UI/Dashboard/TabNotifications.test.js +++ b/src/gui/src/UI/Dashboard/TabNotifications.test.js @@ -369,6 +369,78 @@ describe('your devices', () => { expect($('.push-remove-device').attr('data-uid')).toBe('a'); }); + it('removes this browser through the client, which also forgets its registration', async () => { + const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'a' }); + pushClient.registerPushClient(client); + const remove = vi.fn(() => reply(200, { removed: true })); + let listed = [device({ uid: 'a', current: true }), device({ uid: 'b', label: 'Phone' })]; + serve({ + 'GET /push/devices': () => reply(200, { devices: listed }), + 'GET /push/apps': { apps: [] }, + 'GET /push/preferences': { puter: true }, + 'DELETE /push/devices/a': remove, + }); + await open(); + client.disable.mockImplementation(async () => { + listed = listed.filter(d => d.uid !== 'a'); + client.status.mockResolvedValue({ permission: 'granted', registered: false }); + }); + + $('.push-remove-device[data-uid="a"]').trigger('click'); + await settle(); + dialogButton('push_device_remove').trigger('click'); + await settle(); + + expect(client.disable).toHaveBeenCalledTimes(1); + expect(remove).not.toHaveBeenCalled(); + expect(browserCard().hasClass('push-browser-off')).toBe(true); + expect($('.push-device-card')).toHaveLength(1); + expect($('.push-remove-device').attr('data-uid')).toBe('b'); + }); + + it('removes another device with the server alone', async () => { + const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'a' }); + pushClient.registerPushClient(client); + const remove = vi.fn(() => reply(200, { removed: true })); + serve({ + 'GET /push/devices': { devices: [device({ uid: 'a', current: true }), device({ uid: 'b', label: 'Phone' })] }, + 'GET /push/apps': { apps: [] }, + 'GET /push/preferences': { puter: true }, + 'DELETE /push/devices/b': remove, + }); + await open(); + + $('.push-remove-device[data-uid="b"]').trigger('click'); + await settle(); + dialogButton('push_device_remove').trigger('click'); + await settle(); + + expect(remove).toHaveBeenCalledTimes(1); + expect(client.disable).not.toHaveBeenCalled(); + expect(browserCard().hasClass('push-browser-on')).toBe(true); + expect($('.push-remove-device').attr('data-uid')).toBe('a'); + }); + + it('keeps this browser and says so when the client cannot reach a push service', async () => { + const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'a' }); + client.disable.mockRejectedValue(Object.assign(new Error('unavailable'), { code: 'push_unavailable' })); + pushClient.registerPushClient(client); + serve({ + 'GET /push/devices': { devices: [device({ uid: 'a', current: true })] }, + 'GET /push/apps': { apps: [] }, + 'GET /push/preferences': { puter: true }, + }); + await open(); + + $('.push-remove-device[data-uid="a"]').trigger('click'); + await settle(); + dialogButton('push_device_remove').trigger('click'); + await settle(); + + expect($('.push-device-card')).toHaveLength(1); + expect(dialog('.dashboard-dialog-message').text()).toContain('push_device_remove_failed'); + }); + it('drops a device the server no longer has', async () => { serve({ 'GET /push/devices': { devices: [device({ uid: 'a' })] },