From 2e6fa5ac749e39c53e8edcb87106132c9f63cc72 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 8 Oct 2026 19:28:28 +0200 Subject: [PATCH] Push receiver: import displayed notifications on open; pull-to-refresh listens on the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session-watch push showed on the iPhone but never reached the history, and a pull on the empty part of the page did nothing. (1) The history now heals itself: on every load the page asks the service-worker registration for the notifications still in the notification centre and writes the ones it lacks (entryFromNotification, the rule the click handler already used, now in the worker core) — a push the worker could not persist is caught up as long as its notification has not been cleared. (2) The pull gesture is registered on the window (passive), not on the content wrapper, so a pull that starts below the list counts; the wrapper keeps a minimum height. Tests pin the entry rule. Co-Authored-By: Claude Fable 5.1 --- .../autopilot-monitor-web/app/push/page.tsx | 22 +++++++++++-- .../lib/__tests__/pushCore.test.ts | 25 +++++++++++++++ .../lib/push/pushClient.ts | 32 ++++++++++++++++++- .../public/push/sw-core.js | 17 ++++++++++ .../autopilot-monitor-web/public/push/sw.js | 17 ++-------- 5 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/push/page.tsx b/src/Web/autopilot-monitor-web/app/push/page.tsx index 05e38f59..48d895a5 100644 --- a/src/Web/autopilot-monitor-web/app/push/page.tsx +++ b/src/Web/autopilot-monitor-web/app/push/page.tsx @@ -19,7 +19,7 @@ import { writeMeta, type HistoryEntry, } from "@/lib/push/pushCore"; -import { readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; +import { importDisplayedNotifications, readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; import { formatDateTime, formatRelativeTime } from "@/lib/push/pushFormat"; import { EntryActionSheet } from "./EntryActionSheet"; import { useIsClient, useLocationHash } from "./pushEnvironment"; @@ -69,6 +69,7 @@ export default function PushHistoryPage() { const loadHistory = useCallback(async () => { let list: HistoryEntry[] = []; try { + await importDisplayedNotifications(); await pruneStoredHistory(); list = await readHistory(); } catch { @@ -134,7 +135,22 @@ export default function PushHistoryPage() { onChange: setPull, }), ); - useEffect(() => () => puller.dispose(), [puller]); + // The gesture listens on the window, not on the content: most of the page is empty space + // below the list, and a pull that starts there must count too. Passive — nothing is prevented. + useEffect(() => { + const opts: AddEventListenerOptions = { passive: true }; + window.addEventListener("touchstart", puller.onTouchStart, opts); + window.addEventListener("touchmove", puller.onTouchMove, opts); + window.addEventListener("touchend", puller.onTouchEnd, opts); + window.addEventListener("touchcancel", puller.onTouchCancel, opts); + return () => { + window.removeEventListener("touchstart", puller.onTouchStart); + window.removeEventListener("touchmove", puller.onTouchMove); + window.removeEventListener("touchend", puller.onTouchEnd); + window.removeEventListener("touchcancel", puller.onTouchCancel); + puller.dispose(); + }; + }, [puller]); // The filter is a per-device preference, stored next to the retention; a stale key keeps everything. useEffect(() => { @@ -214,7 +230,7 @@ export default function PushHistoryPage() { const pullLabel = pull.refreshing ? "Refreshing…" : pull.armed ? "Release to refresh" : "Pull to refresh"; return ( -
+
{ + it("takes the entry the push handler attached", () => { + const attached = normalizePayload(FULL_PAYLOAD, NOW); + const entry = entryFromNotification({ title: "x", body: "y", data: { id: attached.id, entry: attached } }, NOW); + expect(entry).toBe(attached); + }); + + it("rebuilds the entry from a platform-displayed notification's data member", () => { + const n = { title: "Enrollment finished", body: "PHS-1 (…36GJ) finished enrollment.", tag: "session_watch", data: FULL_PAYLOAD.notification.data }; + const entry = entryFromNotification(n, NOW)!; + expect(entry.id).toBe(FULL_PAYLOAD.notification.data.id); + expect(entry.title).toBe("Enrollment finished"); + expect(entry.type).toBe("enrollment_failed"); + expect(entry.tag).toBe("session_watch"); + expect(entry.portalUrl).toBe(FULL_PAYLOAD.notification.data.portalUrl); + }); + + it("ignores notifications that are not ours", () => { + expect(entryFromNotification({ title: "foreign", body: "", data: null }, NOW)).toBeNull(); + expect(entryFromNotification({ title: "foreign", body: "", data: { other: 1 } }, NOW)).toBeNull(); + expect(entryFromNotification({ title: "foreign", body: "" }, NOW)).toBeNull(); + }); +}); + describe("normalizeRetentionDays", () => { it("accepts integers 0–365 as numbers or stored strings", () => { expect(normalizeRetentionDays(0)).toBe(0); diff --git a/src/Web/autopilot-monitor-web/lib/push/pushClient.ts b/src/Web/autopilot-monitor-web/lib/push/pushClient.ts index 46d9cce7..53155383 100644 --- a/src/Web/autopilot-monitor-web/lib/push/pushClient.ts +++ b/src/Web/autopilot-monitor-web/lib/push/pushClient.ts @@ -8,11 +8,13 @@ import { base64UrlToUint8Array, bufferToBase64Url, clearPushDb, + entryFromNotification, META_KEYS, PAIR_COOKIE_NAME, readMeta, - writeMeta, type PlatformInput, + writeHistoryEntry, + writeMeta, } from "./pushCore"; import { deleteDevice, @@ -128,6 +130,34 @@ export async function isStoragePersisted(): Promise { // --- Device lifecycle ----------------------------------------------------------------------- +/** + * The notifications still in the notification centre are the one record outside IndexedDB of + * what was delivered. On every open the page imports the ones the history lacks — a push the + * worker could not persist (killed early, a stuck database) is then caught up as long as the + * notification has not been cleared. Returns how many entries were added. + */ +export async function importDisplayedNotifications(): Promise { + const registration = await getPushRegistration().catch(() => null); + if (!registration || typeof registration.getNotifications !== "function") return 0; + let notifications: Notification[]; + try { + notifications = await registration.getNotifications(); + } catch { + return 0; + } + let added = 0; + for (const notification of notifications) { + const entry = entryFromNotification(notification, Date.now()); + if (!entry) continue; + try { + if (await writeHistoryEntry(entry)) added++; + } catch { + // No IndexedDB: nothing to import into. + } + } + return added; +} + /** Drops the subscription and every local trace; used by unpair, the wipe command and "device gone". */ export async function wipeLocalDevice(): Promise { try { diff --git a/src/Web/autopilot-monitor-web/public/push/sw-core.js b/src/Web/autopilot-monitor-web/public/push/sw-core.js index 8d4fdd38..dc1d90c9 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw-core.js +++ b/src/Web/autopilot-monitor-web/public/push/sw-core.js @@ -270,6 +270,23 @@ export function normalizePayload(input, nowMs) { }; } +/** + * The history entry behind a displayed notification: the one the push handler attached + * (data.entry), or — for a notification the platform displayed straight from the declarative + * JSON, without the worker running — rebuilt from the Notification itself, whose data is the + * payload's data member. Used by the click handler and by the pages, which import whatever is + * still in the notification centre on open so a push the worker missed is not lost. + * @param {{ title?: unknown, body?: unknown, tag?: unknown, data?: unknown }} notification + * @param {number} nowMs + * @returns {HistoryEntry | null} null when nothing identifies the notification as ours + */ +export function entryFromNotification(notification, nowMs) { + const data = isRecord(notification.data) ? notification.data : null; + if (data && isRecord(data.entry) && typeof data.entry.id === "string") return /** @type {HistoryEntry} */ (data.entry); + if (!data || typeof data.id !== "string") return null; + return normalizePayload({ title: notification.title, body: notification.body, tag: notification.tag, data }, nowMs); +} + /** * @param {HistoryEntry} entry * @returns {boolean} true for the wipe command (push_revoked): the device must forget everything diff --git a/src/Web/autopilot-monitor-web/public/push/sw.js b/src/Web/autopilot-monitor-web/public/push/sw.js index 944c1183..1ed554e2 100644 --- a/src/Web/autopilot-monitor-web/public/push/sw.js +++ b/src/Web/autopilot-monitor-web/public/push/sw.js @@ -14,6 +14,7 @@ import { bufferToBase64Url, clearPushDb, entryFragment, + entryFromNotification, GENERIC_BODY, GENERIC_TITLE, isWipeCommand, @@ -151,23 +152,9 @@ self.addEventListener("pushnotification", (event) => { 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 entry = entryFromClicked(event.notification); + const entry = entryFromNotification(event.notification, Date.now()); const id = entry ? entry.id : null; const target = new URL("/push/" + (id ? entryFragment(id) : ""), self.location.origin).toString(); event.waitUntil(