From 389fd26bee7045ec5625e97908b6b5fbe1561d7e Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 27 Oct 2026 14:20:17 -0400 Subject: [PATCH] fix(gui): turn on can't run twice at once The browser card's button stays out while the client is enabling or disabling, through any redraw in between, and a status read begun before the press no longer draws the card. --- src/gui/src/UI/Dashboard/TabNotifications.js | 36 +++++--- .../src/UI/Dashboard/TabNotifications.test.js | 86 +++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabNotifications.js b/src/gui/src/UI/Dashboard/TabNotifications.js index d00f6c17c..8ddc75795 100644 --- a/src/gui/src/UI/Dashboard/TabNotifications.js +++ b/src/gui/src/UI/Dashboard/TabNotifications.js @@ -85,11 +85,12 @@ const BROWSER_DETAIL_KEYS = { off: 'push_browser_off', }; -/** @param {BrowserPushState} state */ -export const browserCardHtml = (state) => { +/** @param {BrowserPushState} state @param {{ busy?: boolean }} [opts] - `busy` while the client is enabling or disabling */ +export const browserCardHtml = (state, { busy = false } = {}) => { + const disabled = busy ? ' disabled' : ''; let action = ''; - if ( state === 'on' ) action = ``; - if ( state === 'off' ) action = ``; + if ( state === 'on' ) action = ``; + if ( state === 'off' ) action = ``; return card({ icon: ICONS.bell, title: i18n('push_this_browser'), @@ -200,12 +201,27 @@ const TabNotifications = { // Only the latest status read is drawn, so a slow one can't undo a newer state. let browserSeq = 0; + let browserBusy = false; const refreshBrowser = async () => { const seq = ++browserSeq; const state = await browserPushState(getPushClient()); - if ( seq === browserSeq ) $section.find('.push-browser-card').replaceWith(browserCardHtml(state)); + if ( seq === browserSeq ) { + $section.find('.push-browser-card').replaceWith(browserCardHtml(state, { busy: browserBusy })); + } return state; }; + // One client call at a time: the card's button is out until it settles, + // and a status read begun before it no longer draws the card. + const whileBrowserBusy = async (call) => { + browserBusy = true; + browserSeq++; + $section.find('.push-browser-card button').prop('disabled', true); + try { + return await call(); + } finally { + browserBusy = false; + } + }; const renderDevices = () => { const now = Date.now(); @@ -275,11 +291,10 @@ const TabNotifications = { $section.on('click', '.push-turn-on', async function () { const client = getPushClient(); - if ( ! client ) return; - $(this).prop('disabled', true); + if ( ! client || browserBusy ) return; let deviceUid; try { - ({ deviceUid } = await client.enable()); + ({ deviceUid } = await whileBrowserBusy(() => client.enable())); } catch ( e ) { console.warn('[push] enable failed', e); if ( await refreshBrowser() !== 'blocked' ) showError('push_enable_failed', $el_window); @@ -301,10 +316,9 @@ const TabNotifications = { $section.on('click', '.push-turn-off', async function () { const client = getPushClient(); - if ( ! client ) return; - $(this).prop('disabled', true); + if ( ! client || browserBusy ) return; try { - await client.disable(); + await whileBrowserBusy(() => client.disable()); } catch ( e ) { console.warn('[push] disable failed', e); showError('push_disable_failed', $el_window); diff --git a/src/gui/src/UI/Dashboard/TabNotifications.test.js b/src/gui/src/UI/Dashboard/TabNotifications.test.js index 8a52e9b76..5305a7573 100644 --- a/src/gui/src/UI/Dashboard/TabNotifications.test.js +++ b/src/gui/src/UI/Dashboard/TabNotifications.test.js @@ -241,6 +241,92 @@ describe('this browser', () => { expect(browserCard().hasClass('push-browser-on')).toBe(true); }); + it('turns on once however often the button is pressed while enabling', async () => { + const client = fakeClient(); + pushClient.registerPushClient(client); + let finishEnable; + client.enable.mockImplementation(() => new Promise(resolve => { + finishEnable = resolve; + })); + serve({ + 'GET /push/devices': { devices: [] }, + 'GET /push/apps': { apps: [] }, + 'GET /push/preferences': { puter: true }, + 'POST /push/devices/dev-new/test': () => reply(200, { sent: true }), + }); + await open(); + + $('.push-turn-on').trigger('click'); + await settle(); + expect($('.push-turn-on').prop('disabled')).toBe(true); + // A redraw while enabling keeps the button out. + await tab.onActivate($('.win')); + await settle(); + expect($('.push-turn-on').prop('disabled')).toBe(true); + $('.push-turn-on').trigger('click'); + $('.push-turn-on').prop('disabled', false).trigger('click'); + await settle(); + expect(client.enable).toHaveBeenCalledTimes(1); + + client.status.mockResolvedValue({ permission: 'granted', registered: true, deviceUid: 'dev-new' }); + finishEnable({ deviceUid: 'dev-new' }); + await settle(); + expect(browserCard().hasClass('push-browser-on')).toBe(true); + expect($('.push-turn-off').prop('disabled')).toBe(false); + }); + + it('lets no status read begun before Turn on draw the card while enabling', async () => { + const client = fakeClient(); + pushClient.registerPushClient(client); + await open(); + let finishSlow; + client.status.mockImplementationOnce(() => new Promise(resolve => { + finishSlow = resolve; + })); + let finishEnable; + client.enable.mockImplementation(() => new Promise(resolve => { + finishEnable = resolve; + })); + tab.onActivate($('.win')); + await settle(); + + $('.push-turn-on').trigger('click'); + await settle(); + finishSlow({ permission: 'default', registered: false }); + await settle(); + expect($('.push-turn-on').prop('disabled')).toBe(true); + + client.status.mockResolvedValue({ permission: 'granted', registered: true, deviceUid: 'dev-new' }); + finishEnable({ deviceUid: 'dev-new' }); + await settle(); + expect(browserCard().hasClass('push-browser-on')).toBe(true); + }); + + it('turns off once however often the button is pressed while disabling', async () => { + const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'dev-1' }); + pushClient.registerPushClient(client); + let finishDisable; + client.disable.mockImplementation(() => new Promise(resolve => { + finishDisable = resolve; + })); + await open(); + + $('.push-turn-off').trigger('click'); + await settle(); + await tab.onActivate($('.win')); + await settle(); + expect($('.push-turn-off').prop('disabled')).toBe(true); + $('.push-turn-off').prop('disabled', false).trigger('click'); + await settle(); + expect(client.disable).toHaveBeenCalledTimes(1); + + client.status.mockResolvedValue({ permission: 'granted', registered: false }); + finishDisable(); + await settle(); + expect(browserCard().hasClass('push-browser-off')).toBe(true); + expect($('.push-turn-on').prop('disabled')).toBe(false); + }); + it('turns off through the client', async () => { const client = fakeClient({ permission: 'granted', registered: true, deviceUid: 'dev-1' }); pushClient.registerPushClient(client);