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.
This commit is contained in:
Juan Castro committed 2026-10-24 16:08:00 -04:00
1 parent 4b6b3ada7e
commit 01de7af0ae
7 files changed
+56 -16

No files matched your search

+1
View File
@@ -212,6 +212,7 @@ const launch_app = async (options) => {
name: fallbackAppName,
args: fallbackArgs,
app_obj: undefined,
on_ready: undefined,
privateLaunchRequestedAppName: requestedAppName,
privateLaunchRedirectDepth: privateLaunchRedirectDepth + 1,
});
+3 -2
View File
@@ -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';
+17 -1
View File
@@ -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 }, {
+9 -2
View File
@@ -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);
});
+1
View File
@@ -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,
+7 -9
View File
@@ -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));
});
+18 -2
View File
@@ -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' } }));