From 01de7af0aeb3400d3bbeff77514447b7a2d6be60 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 23 Oct 2026 15:52:50 -0400 Subject: [PATCH] fix(gui): taps reach only the tapped app and open a window in time A notification tap's ready callback sends the click only to a window of the tapped app, and a private-app redirect launches its fallback without it. The worker waits at most 600 ms from the click, lookup included, before opening a window, and the page acknowledges only a well-formed tap. --- src/gui/src/helpers/launchApp.js | 1 + src/gui/src/helpers/notificationTap.js | 5 +++-- src/gui/src/helpers/notificationTap.test.js | 18 +++++++++++++++++- src/gui/src/helpers/pushWorker.js | 11 +++++++++-- src/gui/src/helpers/pushWorker.test.js | 1 + src/gui/src/sw.js | 16 +++++++--------- src/gui/src/sw.test.js | 20 ++++++++++++++++++-- 7 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/gui/src/helpers/launchApp.js b/src/gui/src/helpers/launchApp.js index b69357bed..2f4dcec58 100644 --- a/src/gui/src/helpers/launchApp.js +++ b/src/gui/src/helpers/launchApp.js @@ -212,6 +212,7 @@ const launch_app = async (options) => { name: fallbackAppName, args: fallbackArgs, app_obj: undefined, + on_ready: undefined, privateLaunchRequestedAppName: requestedAppName, privateLaunchRedirectDepth: privateLaunchRedirectDepth + 1, }); diff --git a/src/gui/src/helpers/notificationTap.js b/src/gui/src/helpers/notificationTap.js index eb997385b..31b3056f0 100644 --- a/src/gui/src/helpers/notificationTap.js +++ b/src/gui/src/helpers/notificationTap.js @@ -185,9 +185,10 @@ export const openAppForTap = async ({ appUid, click }, { name: app.name, app_obj: app, on_ready: (process) => { - if ( sent ) return; + const el_win = process?.references?.el_win; + if ( sent || el_win?.dataset?.app_uuid !== appUid ) return; sent = true; - send(process.references?.el_win, NOTIFICATION_CLICK, click); + send(el_win, NOTIFICATION_CLICK, click); }, }); return 'launched'; diff --git a/src/gui/src/helpers/notificationTap.test.js b/src/gui/src/helpers/notificationTap.test.js index 447fb0ab8..e8d9bffd2 100644 --- a/src/gui/src/helpers/notificationTap.test.js +++ b/src/gui/src/helpers/notificationTap.test.js @@ -174,12 +174,28 @@ describe('openAppForTap', () => { expect(options).not.toHaveProperty('args'); expect(send).not.toHaveBeenCalled(); - const el_win = { id: 'new-win' }; + const el_win = { dataset: { app_uuid: 'app-1' } }; options.on_ready({ references: { el_win } }); options.on_ready({ references: { el_win } }); expect(send.mock.calls).toEqual([[el_win, 'notificationClick', click]]); }); + it('sends nothing to a different app that became ready in its place', async () => { + const send = vi.fn(); + let options; + await openAppForTap({ appUid: 'app-1', click }, { + running: () => null, + getApp: async () => ({ uid: 'app-1', name: 'chat' }), + launch: async (opts) => { + options = opts; + }, + send, + }); + options.on_ready({ references: { el_win: { dataset: { app_uuid: 'fallback-app' } } } }); + options.on_ready({ references: {} }); + expect(send).not.toHaveBeenCalled(); + }); + it('does nothing for an app that no longer exists', async () => { const launch = vi.fn(); const outcome = await openAppForTap({ appUid: 'gone', click }, { diff --git a/src/gui/src/helpers/pushWorker.js b/src/gui/src/helpers/pushWorker.js index 6d0ad3cc7..2df3624c4 100644 --- a/src/gui/src/helpers/pushWorker.js +++ b/src/gui/src/helpers/pushWorker.js @@ -20,7 +20,14 @@ // The page's side of `/sw.js`: registration, routed taps, hidden-tab banners. import { httpsIconFor, isShareNotification } from '../UI/Dashboard/notificationCenter.js'; -import { APP_MESSAGE_TYPE, dispatchPushTap, dispatchStoredTap, pushTapFromParams, PUSH_TAP_PARAM } from './notificationTap.js'; +import { + APP_MESSAGE_TYPE, + dispatchPushTap, + dispatchStoredTap, + normalizePushTap, + pushTapFromParams, + PUSH_TAP_PARAM, +} from './notificationTap.js'; import { clear_query_param } from './parseSharedPath.js'; export const WORKER_URL = '/sw.js'; @@ -164,7 +171,7 @@ export const startPushWorker = async ({ return; } nav.serviceWorker.addEventListener('message', (event) => { - if ( event.data?.type !== WORKER_CLICK_MESSAGE ) return; + if ( event.data?.type !== WORKER_CLICK_MESSAGE || ! normalizePushTap(event.data.notification) ) return; event.source?.postMessage?.({ type: WORKER_CLICK_ACK_MESSAGE, id: event.data.id }); dispatchPushTap(event.data.notification); }); diff --git a/src/gui/src/helpers/pushWorker.test.js b/src/gui/src/helpers/pushWorker.test.js index b61f8b212..6194d8a89 100644 --- a/src/gui/src/helpers/pushWorker.test.js +++ b/src/gui/src/helpers/pushWorker.test.js @@ -211,6 +211,7 @@ describe('startPushWorker', () => { const source = { postMessage: vi.fn() }; nav.listeners.message({ data: { type: 'other', notification: { uid: 'x' } }, source }); + nav.listeners.message({ data: { type: 'puter-notification-click', id: 'click-0', notification: { uid: '' } }, source }); nav.listeners.message({ data: { type: 'puter-notification-click', id: 'click-1', notification: { uid: 'n1', target: { app: 'a' } } }, source, diff --git a/src/gui/src/sw.js b/src/gui/src/sw.js index 14c8a95c0..5bb152e30 100644 --- a/src/gui/src/sw.js +++ b/src/gui/src/sw.js @@ -25,7 +25,8 @@ const SHOW_MESSAGE = 'puter-show-notification'; const CLICK_MESSAGE = 'puter-notification-click'; const CLICK_ACK_MESSAGE = 'puter-notification-click-ack'; const CLICK_PARAM = 'puter.notification'; -const CLICK_ACK_TIMEOUT_MS = 1500; +// Browsers allow openWindow only shortly after the click. +const CLICK_ACK_BUDGET_MS = 600; const GUI_PATHS = /^\/(?:$|desktop(?:\/|$)|dashboard(?:\/|$)|app\/|settings(?:\/|$)|@)/; const nonEmptyString = (value) => ( @@ -156,19 +157,15 @@ function waitForClickAck (id, timeoutMs) { }); } -async function routeClick (scope, click, timeoutMs = CLICK_ACK_TIMEOUT_MS) { +async function routeClick (scope, click, startedAt = Date.now(), budgetMs = CLICK_ACK_BUDGET_MS) { const origin = scope.location.origin; const windowClients = await scope.clients.matchAll({ type: 'window', includeUncontrolled: true }); const client = pickWindow(windowClients, origin); if ( client ) { - try { - await client.focus(); - } catch ( _e ) { - // focus is best effort; the message still routes the tap - } const id = `click-${++nextClickId}`; - const acked = waitForClickAck(id, timeoutMs); + const acked = waitForClickAck(id, Math.max(0, startedAt + budgetMs - Date.now())); client.postMessage({ type: CLICK_MESSAGE, id, notification: click }); + client.focus().catch(() => {}); if ( await acked ) return; } await scope.clients.openWindow(clickUrl(click, origin)); @@ -210,6 +207,7 @@ self.addEventListener('message', (event) => { }); self.addEventListener('notificationclick', (event) => { + const startedAt = Date.now(); event.notification.close(); - event.waitUntil(routeClick(self, clickFromData(event.notification.data))); + event.waitUntil(routeClick(self, clickFromData(event.notification.data), startedAt)); }); diff --git a/src/gui/src/sw.test.js b/src/gui/src/sw.test.js index 6e5494796..124f0238d 100644 --- a/src/gui/src/sw.test.js +++ b/src/gui/src/sw.test.js @@ -53,6 +53,7 @@ const loadWorker = ({ windows = [] } = {}) => { console, Map, Promise, + Date: { now: () => globalThis.Date.now() }, setTimeout: (...args) => globalThis.setTimeout(...args), clearTimeout: (...args) => globalThis.clearTimeout(...args), self: scope, @@ -252,12 +253,12 @@ describe('service worker notificationclick', () => { expect(worker.scope.clients.openWindow).not.toHaveBeenCalled(); }); - it('opens Puter when the window does not acknowledge the tap in time', async () => { + it('opens Puter when the window does not acknowledge within 600 ms of the click', async () => { vi.useFakeTimers(); const win = windowClient(`${ORIGIN}/`); const worker = loadWorker({ windows: [win] }); const done = worker.fire('notificationclick', tapped(CLICK)); - await vi.advanceTimersByTimeAsync(1499); + await vi.advanceTimersByTimeAsync(599); expect(worker.scope.clients.openWindow).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); await done; @@ -265,6 +266,21 @@ describe('service worker notificationclick', () => { expect(worker.scope.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/?puter.notification=n1`); }); + it('counts the window lookup against the same 600 ms', async () => { + vi.useFakeTimers(); + const win = windowClient(`${ORIGIN}/`); + const worker = loadWorker(); + worker.scope.clients.matchAll.mockImplementation(() => new Promise((resolve) => { + setTimeout(() => resolve([win]), 400); + })); + const done = worker.fire('notificationclick', tapped(CLICK)); + await vi.advanceTimersByTimeAsync(599); + expect(worker.scope.clients.openWindow).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(worker.scope.clients.openWindow).toHaveBeenCalledOnce(); + }); + it('opens Puter on this origin when no window of its own is open', async () => { const worker = loadWorker({ windows: [windowClient('https://elsewhere.example/')] }); await worker.fire('notificationclick', tapped({ uid: 'n1', target: { path: '/alice/Shared/a.txt' } }));