// OpenPWA device-test lab: shared page module. // // Pure reads of the environment plus a few explicit, button-driven writes // (storage marker, notification permission, a local notification, a push // subscription). Nothing here sends data anywhere: no fetch, no beacon, no // analytics. Results are rendered on the page and copied to the clipboard only // when the tester taps "Copy results as JSON". export const LAB_ROOT = "/lab/device-tests/"; const DB_NAME = "openpwa-lab"; const EVENTS = "events"; const KV = "kv"; const MARKER_KEY = "openpwa-lab-marker"; const MARKER_COOKIE = "openpwa_lab_marker"; const LAST_EVENT_KEY = "openpwa-lab-last-event"; const EVENT_LIMIT = 10; // Same public key as /demo/push/. The private half was discarded when it was // generated, so a subscription made with it can never receive a message. export const LAB_VAPID_PUBLIC_KEY = "BKKZzK80tN6uCHmL3PiQTYmlPl9ljQLFyqPvX7Ozb--0QaljX8U383UNjT_5zwrFnX7x4Txv63aIABmtVm9_Tzk"; const DISPLAY_MODES = Object.freeze(["browser", "minimal-ui", "standalone", "fullscreen", "window-controls-overlay", "picture-in-picture"]); export const displayModes = () => Object.fromEntries(DISPLAY_MODES.map((mode) => [mode, window.matchMedia(`(display-mode: ${mode})`).matches])); /** One word for where the page is running, from what the page itself can see. */ export const contextLabel = () => { const modes = displayModes(); const matched = DISPLAY_MODES.filter((mode) => modes[mode]); const standalone = navigator.standalone === true; const mode = matched[0] || "none matched"; if (standalone || (mode !== "browser" && mode !== "none matched")) return `app (display-mode: ${mode}; navigator.standalone: ${String(navigator.standalone)})`; return `browser tab (display-mode: ${mode}; navigator.standalone: ${String(navigator.standalone)})`; }; export const featureDetects = () => ({ "'serviceWorker' in navigator": "serviceWorker" in navigator, "'Notification' in window": "Notification" in window, "Notification.permission": "Notification" in window ? Notification.permission : "n/a", "'PushManager' in window": "PushManager" in window, "'pushManager' in window (Declarative Web Push)": "pushManager" in window, "typeof window.pushManager?.subscribe": typeof window.pushManager?.subscribe, "typeof window.pushManager?.permissionState": typeof window.pushManager?.permissionState, "'install' in navigator": "install" in navigator, "typeof navigator.install": typeof navigator.install, "'BeforeInstallPromptEvent' in window": "BeforeInstallPromptEvent" in window, "'onbeforeinstallprompt' in window": "onbeforeinstallprompt" in window, "'getInstalledRelatedApps' in navigator": "getInstalledRelatedApps" in navigator, "'setAppBadge' in navigator": "setAppBadge" in navigator, "'share' in navigator": "share" in navigator, "'storage' in navigator": "storage" in navigator, }); /** env(safe-area-inset-*) as the page sees it, measured with a probe element. */ export const safeAreaInsets = () => { const probe = document.createElement("div"); probe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;" + "padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);"; document.body.appendChild(probe); const style = getComputedStyle(probe); const insets = { top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }; probe.remove(); return insets; }; export const viewport = () => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, screen: `${screen.width}x${screen.height}`, devicePixelRatio: window.devicePixelRatio, orientation: screen.orientation ? screen.orientation.type : "n/a", safeAreaInsets: safeAreaInsets(), }); const metaContent = (name) => { const el = document.querySelector(`meta[name="${name}"]`); return el ? el.getAttribute("content") : null; }; export const pageDeclarations = () => ({ manifest: document.querySelector('link[rel="manifest"]')?.getAttribute("href") ?? null, appleTouchIcon: document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href") ?? null, themeColor: metaContent("theme-color"), appleStatusBarStyle: metaContent("apple-mobile-web-app-status-bar-style"), appleTitle: metaContent("apple-mobile-web-app-title"), viewportMeta: metaContent("viewport"), }); // --- IndexedDB ------------------------------------------------------------- const openDb = () => new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(EVENTS)) db.createObjectStore(EVENTS, { keyPath: "id", autoIncrement: true }); if (!db.objectStoreNames.contains(KV)) db.createObjectStore(KV); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const withStore = async (name, mode, fn) => { const db = await openDb(); try { return await new Promise((resolve, reject) => { const tx = db.transaction(name, mode); const result = fn(tx.objectStore(name)); tx.oncomplete = () => resolve(result && "result" in result ? result.result : undefined); tx.onerror = () => reject(tx.error); }); } finally { db.close(); } }; export const readEvents = async () => { const all = await withStore(EVENTS, "readonly", (store) => store.getAll()); return (all || []).slice(-EVENT_LIMIT).reverse(); }; export const clearEvents = async () => { await withStore(EVENTS, "readwrite", (store) => store.clear()); localStorage.removeItem(LAST_EVENT_KEY); }; /** The worker posts each record; the page keeps the latest in localStorage too. */ export const rememberEvent = (record) => { localStorage.setItem(LAST_EVENT_KEY, JSON.stringify(record)); }; export const lastEventFromLocalStorage = () => { try { return JSON.parse(localStorage.getItem(LAST_EVENT_KEY) || "null"); } catch { return null; } }; // --- Storage marker (T6) ----------------------------------------------------- const readCookie = () => { const match = document.cookie.split("; ").find((part) => part.startsWith(`${MARKER_COOKIE}=`)); return match ? decodeURIComponent(match.slice(MARKER_COOKIE.length + 1)) : null; }; export const readMarker = async () => { let indexedDb = null; try { indexedDb = (await withStore(KV, "readonly", (store) => store.get(MARKER_KEY))) ?? null; } catch (error) { indexedDb = `error: ${error.name}`; } return { localStorage: localStorage.getItem(MARKER_KEY), indexedDB: indexedDb, cookie: readCookie() }; }; export const writeMarker = async () => { const where = navigator.standalone === true || !displayModes().browser ? "APP" : "TAB"; const value = `${where}-${new Date().toISOString().slice(0, 19)}Z`; localStorage.setItem(MARKER_KEY, value); await withStore(KV, "readwrite", (store) => store.put(value, MARKER_KEY)); document.cookie = `${MARKER_COOKIE}=${encodeURIComponent(value)}; path=${LAB_ROOT}; max-age=31536000; SameSite=Lax; Secure`; return value; }; export const clearMarker = async () => { localStorage.removeItem(MARKER_KEY); await withStore(KV, "readwrite", (store) => store.delete(MARKER_KEY)); document.cookie = `${MARKER_COOKIE}=; path=${LAB_ROOT}; max-age=0; SameSite=Lax; Secure`; }; // --- Service worker ---------------------------------------------------------- export const registerLabWorker = async () => { if (!("serviceWorker" in navigator)) return { ok: false, detail: "navigator.serviceWorker is missing" }; try { const registration = await navigator.serviceWorker.register(`${LAB_ROOT}sw.js`, { scope: LAB_ROOT }); await navigator.serviceWorker.ready; return { ok: true, detail: `registered, scope ${registration.scope}` }; } catch (error) { return { ok: false, detail: `${error.name}: ${error.message}` }; } }; const activeWorker = async () => { const registration = await navigator.serviceWorker.ready; return { registration, worker: registration.active }; }; /** Ask the worker what its global scope exposes (T8 step 1 without Web Inspector). */ export const probeWorker = () => new Promise((resolve) => { if (!("serviceWorker" in navigator)) { resolve({ error: "no serviceWorker" }); return; } const timer = setTimeout(() => resolve({ error: "no reply from the worker within 4 s" }), 4000); const onMessage = (event) => { if (event.data && event.data.type === "LAB_PROBE") { clearTimeout(timer); navigator.serviceWorker.removeEventListener("message", onMessage); resolve(event.data.exposure); } }; navigator.serviceWorker.addEventListener("message", onMessage); activeWorker() .then(({ worker }) => worker.postMessage({ type: "PROBE" })) .catch((error) => { clearTimeout(timer); resolve({ error: `${error.name}: ${error.message}` }); }); }); const notificationPayload = (label) => ({ title: "OpenPWA lab notification", options: { body: `${label}: tap me (T8)`, tag: "lab-t8", data: { url: `${LAB_ROOT}?from=notification`, createdAt: new Date().toISOString(), label }, }, }); export const showNotificationNow = async () => { const { registration } = await activeWorker(); const { title, options } = notificationPayload("shown immediately"); await registration.showNotification(title, options); return "showNotification resolved"; }; export const showNotificationLater = async (delayMs) => { const { worker } = await activeWorker(); const { title, options } = notificationPayload(`shown by the worker after ${delayMs / 1000} s`); worker.postMessage({ type: "SHOW_DELAYED", delayMs, title, options }); return `asked the worker to show it in ${delayMs / 1000} s; go to the Home Screen now`; }; export const requestPermission = async () => { if (!("Notification" in window)) return "Notification is not defined in this context"; const result = await Notification.requestPermission(); return `Notification.requestPermission() → ${result}`; }; const urlBase64ToUint8Array = (base64) => { const padded = `${base64}${"=".repeat((4 - (base64.length % 4)) % 4)}`.replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(padded); return Uint8Array.from(raw, (char) => char.charCodeAt(0)); }; /** Endpoint host only: the full endpoint and keys never reach the page output. */ export const subscribePush = async () => { const { registration } = await activeWorker(); if (!registration.pushManager) return "registration.pushManager is missing"; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(LAB_VAPID_PUBLIC_KEY), }); return `subscribed; endpoint host ${new URL(subscription.endpoint).host}`; }; export const unsubscribePush = async () => { const { registration } = await activeWorker(); const subscription = registration.pushManager ? await registration.pushManager.getSubscription() : null; if (!subscription) return "no subscription to remove"; await subscription.unsubscribe(); return "unsubscribed"; }; export const pushState = async () => { try { const registration = await navigator.serviceWorker.getRegistration(LAB_ROOT); if (!registration || !registration.pushManager) return { subscribed: false, endpointHost: null }; const subscription = await registration.pushManager.getSubscription(); return { subscribed: Boolean(subscription), endpointHost: subscription ? new URL(subscription.endpoint).host : null }; } catch (error) { return { error: `${error.name}: ${error.message}` }; } }; // --- Results ----------------------------------------------------------------- export const collectResults = async ({ workerExposure, registrationResult } = {}) => ({ collectedAt: new Date().toISOString(), page: location.href, referrer: document.referrer || null, userAgent: navigator.userAgent, context: contextLabel(), "navigator.standalone": navigator.standalone ?? "undefined", displayModes: displayModes(), viewport: viewport(), declarations: pageDeclarations(), featureDetects: featureDetects(), serviceWorker: { registration: registrationResult ?? null, controlled: Boolean(navigator.serviceWorker && navigator.serviceWorker.controller), exposure: workerExposure ?? null, }, push: "serviceWorker" in navigator ? await pushState() : null, storageMarker: await readMarker(), lastEventLocalStorage: lastEventFromLocalStorage(), recentEventsIndexedDB: await readEvents().catch((error) => [`error: ${error.name}`]), }); /** Copy text; falls back to selecting the visible textarea when the API is refused. */ export const copyText = async (text, textarea) => { try { await navigator.clipboard.writeText(text); return "copied to the clipboard"; } catch { textarea.focus(); textarea.select(); const ok = document.execCommand && document.execCommand("copy"); return ok ? "copied to the clipboard" : "copy refused; the text is selected below, use Copy from the menu"; } }; export const formatError = (error) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error)); /** Writes key/value rows into a
. */ export const renderList = (dl, entries) => { dl.replaceChildren( ...Object.entries(entries).flatMap(([key, value]) => { const dt = document.createElement("dt"); dt.textContent = key; const dd = document.createElement("dd"); dd.textContent = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); return [dt, dd]; }), ); }; /** Variant pages: print where the page runs and the geometry of its top element. */ export const bootVariant = () => { const out = document.getElementById("variant-probe"); if (!out) return; const render = () => { const top = document.querySelector("[data-top-element]"); const rect = top ? top.getBoundingClientRect() : null; const geometry = rect ? `${getComputedStyle(top).position}, top ${Math.round(rect.top)}px, height ${Math.round(rect.height)}px, width ${Math.round(rect.width)}px of ${window.innerWidth}px` : "none"; const insets = safeAreaInsets(); out.textContent = [ `context: ${contextLabel()}`, `top element: ${geometry}`, `safe-area-inset-top: ${insets.top}`, `status-bar-style meta: ${metaContent("apple-mobile-web-app-status-bar-style") ?? "absent"}`, `theme-color: ${metaContent("theme-color") ?? "absent"}`, `viewport: ${metaContent("viewport")}`, ].join("\n"); }; render(); window.addEventListener("resize", render); window.addEventListener("scroll", render, { passive: true }); };