mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 21:15:50 +00:00
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:
1 parent
4b6b3ada7e
commit
01de7af0ae
7 files changed
+56
-16
No files matched your search
@@ -212,6 +212,7 @@ const launch_app = async (options) => {
|
||||
name: fallbackAppName,
|
||||
args: fallbackArgs,
|
||||
app_obj: undefined,
|
||||
on_ready: undefined,
|
||||
privateLaunchRequestedAppName: requestedAppName,
|
||||
privateLaunchRedirectDepth: privateLaunchRedirectDepth + 1,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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' } }));
|
||||
|
||||
Reference in new issue
Block a user