fix(gui): removing this browser in Settings also forgets its push registration

This commit is contained in:
Juan Castro committed 2026-10-27 11:07:23 -04:00
1 parent 3068059058
commit 2f94ee1088
3 files changed
+83 -2

No files matched your search

+3 -1
View File
@@ -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.
+8 -1
View File
@@ -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();
@@ -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' })] },