From 559b5d1aee9b9b6ca8c6e6cedd72c43aa6f7b5e4 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 8 Oct 2026 14:58:06 +0200 Subject: [PATCH 1/2] Push receiver: read the declarative notification from the push event (Safari history fix) On an iPhone the pushes arrived with the right content, but every history entry was the generic placeholder: with "mutable": true Safari (iOS >= 18.4) dispatches the regular push event with the proposed Notification in event.notification and event.data null, and the worker read only event.data. The push handler now takes event.notification first and shows the same content itself (one notification, entry written at arrival); a tap on a notification the platform displayed without the worker rebuilds the entry from the Notification's data, and the click path's history write is bounded like the push path's. Tests pin both paths. Co-Authored-By: Claude Fable 5.1 --- .../lib/__tests__/pushSw.test.ts | 103 +++++++++++++++++- .../autopilot-monitor-web/public/push/sw.js | 43 +++++--- 2 files changed, 126 insertions(+), 20 deletions(-) diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts index f687c10f0..42ad532a7 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushSw.test.ts @@ -11,9 +11,16 @@ import { GENERIC_BODY, GENERIC_TITLE } from "../push/pushCore"; type Handler = (event: unknown) => void; +interface FakeClients { + claim: ReturnType; + matchAll: ReturnType; + openWindow: ReturnType; +} + interface FakeSelf { handlers: Map; showNotification: ReturnType; + clients: FakeClients; /** Order of the side effects the contract cares about. */ sequence: string[]; } @@ -24,6 +31,11 @@ function fakeSelf(): FakeSelf { const showNotification = vi.fn(async () => { sequence.push("showNotification"); }); + const clients: FakeClients = { + claim: vi.fn(async () => undefined), + matchAll: vi.fn(async () => []), + openWindow: vi.fn(async () => null), + }; const self = { addEventListener: (type: string, handler: Handler) => { handlers.set(type, handler); @@ -33,15 +45,11 @@ function fakeSelf(): FakeSelf { showNotification, pushManager: { getSubscription: vi.fn(async () => null) }, }, - clients: { - claim: vi.fn(async () => undefined), - matchAll: vi.fn(async () => []), - openWindow: vi.fn(async () => null), - }, + clients, location: { origin: "https://portal.example.test" }, }; vi.stubGlobal("self", self); - return { handlers, showNotification, sequence }; + return { handlers, showNotification, clients, sequence }; } /** An IDBFactory whose open() request never fires any handler. */ @@ -210,4 +218,87 @@ describe("push service worker glue (K25)", () => { await expect(event.waited).resolves.toBeDefined(); }); }); + + describe("push event carrying a declarative notification (Push API mutable, Safari ≥ 18.4)", () => { + const declared = { + title: "Enrollment finished", + body: "DESKTOP-4711 (…CDE9) finished enrollment.", + tag: "session-x", + data: { id: "srv-1", type: "session_watch", ts: "2026-10-08T12:40:15Z", severity: "success", facts: [], portalUrl: "https://portal.example.test/sessions?id=x", scope: "platform" }, + }; + + it("reads event.notification when event.data is null, shows the same content and persists it", async () => { + const env = await loadWorker(hangingIndexedDb); + const event = fakeEvent({ data: null, notification: declared }); + + env.handlers.get("push")!(event); + + expect(env.showNotification).toHaveBeenCalledTimes(1); + expect(env.showNotification.mock.calls[0][0]).toBe("Enrollment finished"); + expect(env.showNotification.mock.calls[0][1]).toMatchObject({ + body: "DESKTOP-4711 (…CDE9) finished enrollment.", + tag: "session-x", + data: { id: "srv-1", entry: expect.objectContaining({ id: "srv-1", type: "session_watch", severity: "success", portalUrl: "https://portal.example.test/sessions?id=x", scope: "platform" }) }, + }); + expect(env.sequence).toEqual(["showNotification", "indexedDB.open"]); + }); + + it("prefers event.notification over an unreadable event.data", async () => { + const env = await loadWorker(hangingIndexedDb); + const event = fakeEvent({ data: UNREADABLE_DATA, notification: declared }); + + env.handlers.get("push")!(event); + + expect(env.showNotification.mock.calls[0][0]).toBe("Enrollment finished"); + }); + }); + + describe("notificationclick on a notification the platform displayed itself", () => { + function clickEvent(notification: Record) { + const event = fakeEvent({}) as FakeEvent & { notification: Record }; + event.notification = { close: vi.fn(), ...notification }; + return event; + } + + const platformShown = { + title: "Enrollment failed", + body: "DESKTOP-4711 (…CDE9) failed enrollment.", + tag: "session-y", + data: { id: "srv-2", type: "session_watch", ts: "2026-10-08T12:45:00Z", severity: "error", facts: [{ name: "Device", value: "DESKTOP-4711" }], portalUrl: null, scope: "tenant" }, + }; + + it("rebuilds the history entry from the notification's data, writes it and opens the entry", async () => { + const env = await loadWorker(erroringIndexedDb); + const event = clickEvent(platformShown); + + env.handlers.get("notificationclick")!(event); + await expect(event.waited).resolves.toBeUndefined(); + + expect(env.indexedDbOpen).toHaveBeenCalledTimes(1); + expect(env.clients.openWindow).toHaveBeenCalledTimes(1); + expect(String(env.clients.openWindow.mock.calls[0][0])).toContain("srv-2"); + }); + + it("opens the page even when the history write never answers (bounded like the push path)", async () => { + const env = await loadWorker(hangingIndexedDb); + const event = clickEvent(platformShown); + + env.handlers.get("notificationclick")!(event); + await vi.advanceTimersByTimeAsync(5000); + await expect(event.waited).resolves.toBeUndefined(); + + expect(env.clients.openWindow).toHaveBeenCalledTimes(1); + }); + + it("keeps using the entry the push handler attached when there is one", async () => { + const env = await loadWorker(erroringIndexedDb); + const attached = { id: "srv-3", ts: "2026-10-08T12:50:00Z", title: "t", body: "b", type: "push_test", severity: "info", facts: [], portalUrl: null, navigate: null, tag: null, scope: "platform", generic: false }; + const event = clickEvent({ title: "t", body: "b", data: { id: "srv-3", entry: attached } }); + + env.handlers.get("notificationclick")!(event); + await expect(event.waited).resolves.toBeUndefined(); + + expect(String(env.clients.openWindow.mock.calls[0][0])).toContain("srv-3"); + }); + }); }); diff --git a/src/Web/autopilot-monitor-web/public/push/sw.js b/src/Web/autopilot-monitor-web/public/push/sw.js index 2e73fd230..8ebe646d3 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw.js +++ b/src/Web/autopilot-monitor-web/public/push/sw.js @@ -133,33 +133,48 @@ function handlePush(event, raw) { event.waitUntil(Promise.all([show(entry), persist(entry)])); } +// Declarative Web Push with "mutable": true (Push API draft; Safari / iOS ≥ 18.4, verified on a +// device 2026-10-08): the regular push event carries the proposed Notification in +// event.notification and event.data is null. The worker shows the same content itself, so the +// proposed notification is dropped and exactly one appears, and the history gets its entry at +// arrival instead of only after a tap. Every other browser hands the raw JSON as event.data. self.addEventListener("push", (event) => { - handlePush(event, safeParse(event.data)); + const declared = event.notification ?? null; + handlePush(event, declared ?? safeParse(event.data)); }); -// Safari / iOS ≥ 18.4: Declarative Web Push with "mutable": true wakes the worker with the -// proposed notification instead of a payload (event name verified at interop, K25). +// The earlier WebKit shape of the same mechanism: a separate pushnotification event with the +// proposed notification (kept until no supported Safari fires it). self.addEventListener("pushnotification", (event) => { const proposed = event.notification ?? event.proposedNotification ?? null; handlePush(event, proposed ?? safeParse(event.data ?? null)); }); +/** + * The history entry behind a clicked notification: the one the push handler attached, or — + * for a notification the platform displayed straight from the declarative JSON, without the + * worker running — rebuilt from the Notification itself (its data is the payload's data member). + * @param {Notification} notification + * @returns {import("./sw-core.js").HistoryEntry | null} + */ +function entryFromClicked(notification) { + const data = notification.data ?? {}; + if (data.entry && typeof data.entry === "object") return data.entry; + if (typeof data.id !== "string") return null; + return normalizePayload({ title: notification.title, body: notification.body, tag: notification.tag, data }, Date.now()); +} + self.addEventListener("notificationclick", (event) => { event.notification.close(); - const data = event.notification.data ?? {}; - const id = typeof data.id === "string" ? data.id : null; - const entry = data.entry && typeof data.entry === "object" ? data.entry : null; + const entry = entryFromClicked(event.notification); + const id = entry ? entry.id : null; const target = new URL("/push/" + (id ? entryFragment(id) : ""), self.location.origin).toString(); event.waitUntil( (async () => { - // The push handler may have lost the race against IndexedDB; the click writes the entry if missing. - if (entry) { - try { - await writeHistoryEntry(entry); - } catch { - // Display already happened; the history is best effort. - } - } + // The push handler may have lost the race against IndexedDB, or never ran: the click writes + // the entry if missing — bounded like every history write, so a stuck database cannot keep + // the tap from opening the page. + if (entry) await persist(entry); const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const client of windows) { if (!new URL(client.url).pathname.startsWith("/push")) continue; From 617eb9396b926d74ed9f63dc520042b6182773ae Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 8 Oct 2026 14:58:07 +0200 Subject: [PATCH 2/2] bump pointer: internal (push-relay: Safari declarative-push finding) Co-Authored-By: Claude Fable 5.1 --- internal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal b/internal index 01d422f72..71ee3d7ca 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 01d422f720b16307d6b3ea35323e0d7260009752 +Subproject commit 71ee3d7ca8896677c2450c3026207daa7879f544