Salvaged from agent/m3-mobile-pwa (built pre-alignment; not mergeable) and adapted to the current structure. Foreground reconnect: - foreground-recovery.ts single-flights a managed db.reconnect() on BFCache pageshow, visibilitychange, and online signals, with offline deferral and failure containment; lifecycle.ts wires it to the runtime and it is surfaced in the dev inspector. PWA install/update lifecycle: - pwa.ts owns the install prompt behind a prompt() action, detects iOS standalone / manual-install, and exposes visible, user-triggered service worker updates; sw.js gates skipWaiting on a LOFI_SKIP_WAITING message and reports precache / runtime-cache failures to the page. Auth foundation: - device-capabilities.ts now reports webAuthn and prf (WebAuthn PRF) support — the capability signal for local-first cryptographic auth. Deferred to a dedicated auth PR: RP-ID/credential-origin classification and the passkey-check probe (coupled to the removed Deno Tunnel path) and raster install icons (need binary-asset support in the generator).
- commit
- 10273a35d9456c2658378a6a198b9aab3a623f33
- parent
- 8397e65635abc9e798962c242693e3424b2cd4b8
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 39 | +++++- |
| M | apps/ | 1 | + |
| M | apps/ | 28 | ++++- |
| M | apps/ | 2 | + |
| A | apps/ | 95 | ++++++++++++++ |
| A | apps/ | 150 | +++++++++++++++++++++++ |
| M | apps/ | 12 | ++ |
| M | apps/ | 7 | + |
| A | apps/ | 30 | +++++ |
| M | apps/ | 8 | + |
| M | apps/ | 198 | ++++++++++++++++++++++++++++-- |
| M | apps/ | 40 | ++++++- |
| M | package/ | 3 | + |
| M | package/ | 21 | ++- |
@@ -1,18 +1,36 @@
11 const BUILD_REVISION = "__LOFI_BUILD_REVISION__";22 const CACHE_NAME = `lofi-shell-${BUILD_REVISION}`;33 4+async function reportFailure(code, message) {5+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });6+ for (const client of windows) {7+ client.postMessage({ type: "LOFI_PWA_FAILURE", code, message });8+ }9+}10+411 self.addEventListener("install", (event) => {512 event.waitUntil((async () => {6- const manifestUrl = new URL("./lofi-precache.json", self.registration.scope);7- const response = await fetch(manifestUrl, { cache: "no-store" });8- if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);9- const paths = await response.json();10- const cache = await caches.open(CACHE_NAME);11- await cache.addAll(paths.map((path) => new URL(path, self.registration.scope)));12- await self.skipWaiting();13+ try {14+ const manifestUrl = new URL("./lofi-precache.json", self.registration.scope);15+ const response = await fetch(manifestUrl, { cache: "no-store" });16+ if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);17+ const paths = await response.json();18+ const cache = await caches.open(CACHE_NAME);19+ await cache.addAll(paths.map((path) => new URL(path, self.registration.scope)));20+ } catch (error) {21+ await reportFailure(22+ "precache",23+ error instanceof Error ? error.message : "precache failed",24+ );25+ throw error;26+ }1327 })());1428 });1529 30+self.addEventListener("message", (event) => {31+ if (event.data?.type === "LOFI_SKIP_WAITING") event.waitUntil(self.skipWaiting());32+});33+1634 self.addEventListener("activate", (event) => {1735 event.waitUntil((async () => {1836 for (const name of await caches.keys()) {@@ -35,7 +53,12 @@ self.addEventListener("fetch", (event) => {
3553 const response = await fetch(event.request);3654 if (response.ok) {3755 const cache = await caches.open(CACHE_NAME);38- void cache.put(event.request, response.clone());56+ void cache.put(event.request, response.clone()).catch((error) =>57+ reportFailure(58+ "runtime-cache",59+ error instanceof Error ? error.message : "runtime cache write failed",60+ )61+ );3962 }4063 return response;4164 } catch (error) {@@ -5,6 +5,8 @@ export type DeviceCapabilityReport = {
55 webLocks: boolean;66 messageChannel: boolean;77 durableDriverSupported: boolean;8+ webAuthn: boolean;9+ prf: "available" | "not-reported" | "unknown" | "unavailable";810 persistentPermission: "granted" | "not-granted" | "unavailable" | "error";911 displayMode: "standalone" | "browser";1012 };@@ -19,6 +21,24 @@ function browserStorage(): StorageManagerWithOpfs | undefined {
1921 : navigator.storage as StorageManagerWithOpfs | undefined;2022 }2123 24+type PublicKeyCredentialCapabilities = typeof PublicKeyCredential & {25+ getClientCapabilities?: () => Promise<Record<string, boolean>>;26+};27+28+async function readPrfCapability(29+ webAuthn: boolean,30+): Promise<DeviceCapabilityReport["prf"]> {31+ if (!webAuthn) return "unavailable";32+ const publicKeyCredential = PublicKeyCredential as PublicKeyCredentialCapabilities;33+ if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";34+ try {35+ const capabilities = await publicKeyCredential.getClientCapabilities();36+ return capabilities["extension:prf"] === true ? "available" : "not-reported";37+ } catch {38+ return "unknown";39+ }40+}41+2242 export function durableCapabilityReport(): Omit<2343 DeviceCapabilityReport,2444 "persistentPermission"@@ -37,6 +57,8 @@ export function durableCapabilityReport(): Omit<
3757 webLocks,3858 messageChannel,3959 durableDriverSupported: secureContext && opfs && sharedWorker && webLocks && messageChannel,60+ webAuthn: typeof PublicKeyCredential === "function",61+ prf: "unknown",4062 displayMode: typeof globalThis.matchMedia === "function" &&4163 globalThis.matchMedia("(display-mode: standalone)").matches4264 ? "standalone"@@ -47,16 +69,18 @@ export function durableCapabilityReport(): Omit<
4769 export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityReport> {4870 const base = durableCapabilityReport();4971 const storage = browserStorage();72+ const prf = await readPrfCapability(base.webAuthn);5073 if (typeof storage?.persisted !== "function") {51- return { ...base, persistentPermission: "unavailable" };74+ return { ...base, prf, persistentPermission: "unavailable" };5275 }5376 try {5477 return {5578 ...base,79+ prf,5680 persistentPermission: await storage.persisted() ? "granted" : "not-granted",5781 };5882 } catch {59- return { ...base, persistentPermission: "error" };83+ return { ...base, prf, persistentPermission: "error" };6084 }6185 }6286 @@ -13,6 +13,8 @@ test("unsupported durable storage names missing capabilities and the stable-orig
1313 webLocks: false,1414 messageChannel: false,1515 durableDriverSupported: false,16+ webAuthn: false,17+ prf: "unavailable",1618 displayMode: "browser",1719 });1820 assert(error.name === "DurableStorageUnsupportedError", "durability error needs a stable name");@@ -0,0 +1,95 @@
1+export type ForegroundRecoveryReason = "pageshow" | "visibilitychange" | "online";2+3+export type ForegroundRecoveryState = {4+ mode: "local-only" | "managed";5+ status: "idle" | "offline-deferred" | "recovering" | "completed" | "failed";6+ attempts: number;7+ lastReason: ForegroundRecoveryReason | "none";8+};9+10+export type ForegroundRecovery = {11+ getState(): ForegroundRecoveryState;12+ subscribe(listener: () => void): () => void;13+ request(reason: ForegroundRecoveryReason): Promise<void>;14+ dispose(): void;15+};16+17+export function createForegroundRecovery(options: {18+ enabled: boolean;19+ pageTarget: EventTarget;20+ visibilityTarget: EventTarget;21+ isVisible(): boolean;22+ isOnline(): boolean;23+ reconnect(): Promise<void>;24+}): ForegroundRecovery {25+ let active = true;26+ let recovery: Promise<void> | null = null;27+ let state: ForegroundRecoveryState = {28+ mode: options.enabled ? "managed" : "local-only",29+ status: "idle",30+ attempts: 0,31+ lastReason: "none",32+ };33+ const listeners = new Set<() => void>();34+ const publish = (patch: Partial<ForegroundRecoveryState>) => {35+ state = { ...state, ...patch };36+ for (const listener of listeners) listener();37+ };38+ const request = (reason: ForegroundRecoveryReason): Promise<void> => {39+ if (!active || !options.enabled) return Promise.resolve();40+ if (reason !== "online" && !options.isOnline()) {41+ publish({ status: "offline-deferred", lastReason: reason });42+ return Promise.resolve();43+ }44+ if (recovery) return recovery;45+ publish({ status: "recovering", attempts: state.attempts + 1, lastReason: reason });46+ const operation = options.reconnect().then(47+ () => publish({ status: "completed" }),48+ (error) => {49+ publish({ status: "failed" });50+ throw error;51+ },52+ );53+ const tracked = operation.finally(() => {54+ if (recovery === tracked) recovery = null;55+ });56+ recovery = tracked;57+ return tracked;58+ };59+ const requestFromEvent = (reason: ForegroundRecoveryReason) => {60+ void request(reason).catch(() => {61+ // The state already records the failed recovery. Browser event callbacks62+ // have no promise consumer, so the failure must not become unhandled.63+ });64+ };65+ const onPageShow = (event: Event) => {66+ if ((event as PageTransitionEvent).persisted === true) requestFromEvent("pageshow");67+ };68+ const onVisibilityChange = () => {69+ if (options.isVisible()) requestFromEvent("visibilitychange");70+ };71+ const onOnline = () => requestFromEvent("online");72+ if (options.enabled) {73+ options.pageTarget.addEventListener("pageshow", onPageShow);74+ options.pageTarget.addEventListener("online", onOnline);75+ options.visibilityTarget.addEventListener("visibilitychange", onVisibilityChange);76+ }77+ return {78+ getState: () => ({ ...state }),79+ subscribe(listener) {80+ listeners.add(listener);81+ return () => {82+ listeners.delete(listener);83+ };84+ },85+ request,86+ dispose() {87+ if (!active) return;88+ active = false;89+ listeners.clear();90+ options.pageTarget.removeEventListener("pageshow", onPageShow);91+ options.pageTarget.removeEventListener("online", onOnline);92+ options.visibilityTarget.removeEventListener("visibilitychange", onVisibilityChange);93+ },94+ };95+}@@ -0,0 +1,150 @@
1+import { createForegroundRecovery } from "./foreground-recovery.ts";2+import { assert, assertCount } from "./test-assert.ts";3+4+const test = (globalThis as unknown as {5+ Deno: { test(name: string, body: () => void | Promise<void>): void };6+}).Deno.test;7+8+function event(name: string, values: Record<string, unknown> = {}): Event {9+ return Object.assign(new Event(name), values);10+}11+12+test("foreground signals single-flight a managed reconnect", async () => {13+ const page = new EventTarget();14+ const document = new EventTarget();15+ let resolveReconnect: (() => void) | undefined;16+ let reconnects = 0;17+ const manager = createForegroundRecovery({18+ enabled: true,19+ pageTarget: page,20+ visibilityTarget: document,21+ isVisible: () => true,22+ isOnline: () => true,23+ reconnect: () => {24+ reconnects += 1;25+ return new Promise<void>((resolve) => resolveReconnect = resolve);26+ },27+ });28+ document.dispatchEvent(event("visibilitychange"));29+ page.dispatchEvent(event("online"));30+ assertCount(reconnects, 1, "overlapping foreground signals stacked reconnect calls");31+ resolveReconnect?.();32+ await Promise.resolve();33+ await Promise.resolve();34+ assert(manager.getState().status === "completed", "completed reconnect was not observable");35+ assertCount(manager.getState().attempts, 1, "single-flight reconnect counted twice");36+ manager.dispose();37+});38+39+test("offline foreground recovery defers until the online event", async () => {40+ const page = new EventTarget();41+ const document = new EventTarget();42+ let online = false;43+ let reconnects = 0;44+ const manager = createForegroundRecovery({45+ enabled: true,46+ pageTarget: page,47+ visibilityTarget: document,48+ isVisible: () => true,49+ isOnline: () => online,50+ reconnect: () => {51+ reconnects += 1;52+ return Promise.resolve();53+ },54+ });55+ document.dispatchEvent(event("visibilitychange"));56+ assert(manager.getState().status === "offline-deferred", "offline state was not retained");57+ assertCount(reconnects, 0, "offline foreground signal attempted reconnect");58+ online = true;59+ page.dispatchEvent(event("online"));60+ await Promise.resolve();61+ await Promise.resolve();62+ assertCount(reconnects, 1, "online recovery did not request reconnect");63+ manager.dispose();64+});65+66+test("pageshow recovers only a restored BFCache document", async () => {67+ const page = new EventTarget();68+ const document = new EventTarget();69+ let reconnects = 0;70+ const manager = createForegroundRecovery({71+ enabled: true,72+ pageTarget: page,73+ visibilityTarget: document,74+ isVisible: () => true,75+ isOnline: () => true,76+ reconnect: () => {77+ reconnects += 1;78+ return Promise.resolve();79+ },80+ });81+ page.dispatchEvent(event("pageshow", { persisted: false }));82+ page.dispatchEvent(event("pageshow", { persisted: true }));83+ await Promise.resolve();84+ assertCount(reconnects, 1, "pageshow did not distinguish initial load from BFCache recovery");85+ manager.dispose();86+});87+88+test("local-only lifecycle attaches no reconnect behavior", async () => {89+ const page = new EventTarget();90+ const document = new EventTarget();91+ let reconnects = 0;92+ const manager = createForegroundRecovery({93+ enabled: false,94+ pageTarget: page,95+ visibilityTarget: document,96+ isVisible: () => true,97+ isOnline: () => true,98+ reconnect: () => {99+ reconnects += 1;100+ return Promise.resolve();101+ },102+ });103+ page.dispatchEvent(event("online"));104+ document.dispatchEvent(event("visibilitychange"));105+ await manager.request("online");106+ assertCount(reconnects, 0, "local-only mode invented a transport recovery action");107+ assert(manager.getState().mode === "local-only", "local-only lifecycle mode was hidden");108+ manager.dispose();109+});110+111+test("event recovery records a reconnect rejection without leaving it unhandled", async () => {112+ const page = new EventTarget();113+ const document = new EventTarget();114+ const manager = createForegroundRecovery({115+ enabled: true,116+ pageTarget: page,117+ visibilityTarget: document,118+ isVisible: () => true,119+ isOnline: () => true,120+ reconnect: () => Promise.reject(new Error("sentinel reconnect failure")),121+ });122+ page.dispatchEvent(event("online"));123+ await Promise.resolve();124+ await Promise.resolve();125+ assert(manager.getState().status === "failed", "reconnect failure was not observable");126+ manager.dispose();127+});128+129+test("disposed lifecycle removes every browser event listener", async () => {130+ const page = new EventTarget();131+ const document = new EventTarget();132+ let reconnects = 0;133+ const manager = createForegroundRecovery({134+ enabled: true,135+ pageTarget: page,136+ visibilityTarget: document,137+ isVisible: () => true,138+ isOnline: () => true,139+ reconnect: () => {140+ reconnects += 1;141+ return Promise.resolve();142+ },143+ });144+ manager.dispose();145+ page.dispatchEvent(event("online"));146+ page.dispatchEvent(event("pageshow", { persisted: true }));147+ document.dispatchEvent(event("visibilitychange"));148+ await Promise.resolve();149+ assertCount(reconnects, 0, "disposed lifecycle retained a browser listener");150+});@@ -24,6 +24,13 @@ export type InspectorSnapshot = {
2424 mutationListeners: number;2525 mutationErrors: number;2626 };27+ lifecycle: {28+ mode: "local-only" | "managed";29+ status: "idle" | "offline-deferred" | "recovering" | "completed" | "failed";30+ attempts: number;31+ lastReason: "none" | "pageshow" | "visibilitychange" | "online";32+ transportDetail: "live detail unavailable" | "not configured";33+ };2734 multiTab: {2835 role: "unavailable" | "leader" | "follower";2936 detail: string;@@ -60,6 +67,11 @@ export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {
6067 { label: "Vendor subscriptions", value: String(snapshot.runtime.vendorSubscriptions) },6168 { label: "Mutation listeners", value: String(snapshot.runtime.mutationListeners) },6269 { label: "Mutation errors", value: String(snapshot.runtime.mutationErrors) },70+ { label: "Lifecycle mode", value: snapshot.lifecycle.mode },71+ { label: "Lifecycle action", value: snapshot.lifecycle.status },72+ { label: "Lifecycle attempts", value: String(snapshot.lifecycle.attempts) },73+ { label: "Lifecycle reason", value: snapshot.lifecycle.lastReason },74+ { label: "Lifecycle transport", value: snapshot.lifecycle.transportDetail },6375 { label: "Multi-tab role", value: snapshot.multiTab.role },6476 { label: "Multi-tab detail", value: snapshot.multiTab.detail },6577 ];@@ -25,6 +25,13 @@ const snapshot: InspectorSnapshot = {
2525 mutationListeners: 1,2626 mutationErrors: 0,2727 },28+ lifecycle: {29+ mode: "managed",30+ status: "completed",31+ attempts: 2,32+ lastReason: "visibilitychange",33+ transportDetail: "live detail unavailable",34+ },2835 multiTab: {2936 role: "unavailable",3037 detail: "Jazz alpha.53 exposes no supported leader/follower signal",@@ -0,0 +1,30 @@
1+import { serverUrl } from "./config.ts";2+import { createForegroundRecovery, type ForegroundRecoveryState } from "./foreground-recovery.ts";3+import { getRuntime } from "./runtime.ts";4+5+const recovery = typeof document === "undefined" ? null : createForegroundRecovery({6+ enabled: Boolean(serverUrl),7+ pageTarget: globalThis,8+ visibilityTarget: document,9+ isVisible: () => document.visibilityState === "visible",10+ isOnline: () => navigator.onLine,11+ reconnect: async () => {12+ const runtime = await getRuntime();13+ await runtime.db.reconnect();14+ },15+});16+17+export function getForegroundRecoveryState(): ForegroundRecoveryState {18+ return recovery?.getState() ?? {19+ mode: serverUrl ? "managed" : "local-only",20+ status: "idle",21+ attempts: 0,22+ lastReason: "none",23+ };24+}25+26+export function subscribeForegroundRecovery(listener: () => void): () => void {27+ return recovery?.subscribe(listener) ?? (() => undefined);28+}29+30+import.meta.hot?.dispose(() => recovery?.dispose());@@ -1,6 +1,7 @@
11 import { serverUrl } from "./config.ts";22 import { readDeviceCapabilityReport } from "./device-capabilities.ts";33 import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";4+import { getForegroundRecoveryState, subscribeForegroundRecovery } from "./lifecycle.ts";45 import { getRuntime, getRuntimeDiagnostics, subscribeRuntimeDiagnostics } from "./runtime.ts";56 67 export type LofiDevelopmentBridge = InspectorAdapter;@@ -31,6 +32,7 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
3132 readDeviceCapabilityReport(),3233 ]);3334 const diagnostics = getRuntimeDiagnostics();35+ const lifecycle = getForegroundRecoveryState();3436 const authMode = runtime.db.getAuthState().authMode;3537 return {3638 identity: {@@ -70,6 +72,10 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
7072 mutationListeners: diagnostics.activeMutationListeners,7173 mutationErrors: diagnostics.mutationErrors,7274 },75+ lifecycle: {76+ ...lifecycle,77+ transportDetail: serverUrl ? "live detail unavailable" : "not configured",78+ },7379 multiTab: {7480 role: "unavailable",7581 detail: "Jazz alpha.53 exposes no supported leader/follower signal",@@ -82,12 +88,14 @@ const bridge: LofiDevelopmentBridge = {
8288 subscribe(listener) {8389 stateListeners.add(listener);8490 const unsubscribeRuntime = subscribeRuntimeDiagnostics(listener);91+ const unsubscribeLifecycle = subscribeForegroundRecovery(listener);8592 let active = true;8693 return () => {8794 if (!active) return;8895 active = false;8996 stateListeners.delete(listener);9097 unsubscribeRuntime();98+ unsubscribeLifecycle();9199 };92100 },93101 async setTransportPaused(paused) {@@ -1,22 +1,119 @@
11 /// <reference path="../env.d.ts" />2-3-export type PwaRegistrationState =2+export type PwaWorkerState =43 | "development-disabled"54 | "unsupported"65 | "registering"76 | "ready"7+ | "update-available"88 | "failed";99 10+export type PwaInstallState =11+ | "installed"12+ | "available"13+ | "prompting"14+ | "accepted"15+ | "dismissed"16+ | "manual-ios"17+ | "unavailable";18+19+export type PwaFailureCode = "registration" | "installation" | "precache" | "runtime-cache";20+21+export type PwaState = {22+ worker: PwaWorkerState;23+ install: PwaInstallState;24+ failure?: {25+ code: PwaFailureCode;26+ message: string;27+ };28+};29+30+type InstallPromptEvent = Event & {31+ prompt(): Promise<void>;32+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;33+};34+35+type NavigatorWithStandalone = Navigator & { standalone?: boolean };36+37+type InstallEnvironment = {38+ displayModeStandalone: boolean;39+ navigatorStandalone: boolean;40+ platform: string;41+ maxTouchPoints: number;42+};43+1044 declare global {1145 interface Window {12- __LOFI_PWA_STATE__?: PwaRegistrationState;46+ __LOFI_PWA_STATE__?: PwaState;1347 }1448 }1549 16-function setState(state: PwaRegistrationState) {50+const subscribers = new Set<(state: PwaState) => void>();51+let deferredInstallPrompt: InstallPromptEvent | undefined;52+let waitingWorker: ServiceWorker | undefined;53+let reloadForUpdate = false;54+let initialized = false;55+56+let state: PwaState = {57+ worker: "registering",58+ install: "unavailable",59+};60+61+function publish(next: PwaState) {62+ state = next;1763 if (typeof document !== "undefined") {1864 (globalThis as typeof globalThis & Window).__LOFI_PWA_STATE__ = state;1965 }66+ for (const subscriber of subscribers) subscriber(state);67+}68+69+function update(patch: Partial<PwaState>) {70+ publish({ ...state, ...patch });71+}72+73+export function getPwaState(): PwaState {74+ return state;75+}76+77+export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {78+ subscribers.add(subscriber);79+ subscriber(state);80+ return () => subscribers.delete(subscriber);81+}82+83+export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {84+ if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";85+ const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||86+ (environment.platform === "MacIntel" && environment.maxTouchPoints > 1);87+ return iosLike ? "manual-ios" : "unavailable";88+}89+90+function currentInstallExperience(): PwaInstallState {91+ const standalone = navigator as NavigatorWithStandalone;92+ return classifyInstallExperience({93+ displayModeStandalone: matchMedia("(display-mode: standalone)").matches,94+ navigatorStandalone: standalone.standalone === true,95+ platform: navigator.platform,96+ maxTouchPoints: navigator.maxTouchPoints,97+ });98+}99+100+export async function requestPwaInstall(): Promise<PwaInstallState> {101+ const prompt = deferredInstallPrompt;102+ if (!prompt) return state.install;103+ deferredInstallPrompt = undefined;104+ update({ install: "prompting" });105+ await prompt.prompt();106+ const choice = await prompt.userChoice;107+ const outcome = choice.outcome === "accepted" ? "accepted" : "dismissed";108+ update({ install: outcome });109+ return outcome;110+}111+112+export function applyPwaUpdate(): boolean {113+ if (!waitingWorker) return false;114+ reloadForUpdate = true;115+ waitingWorker.postMessage({ type: "LOFI_SKIP_WAITING" });116+ return true;20117 }21118 22119 export function waitForActivation(worker: ServiceWorker): Promise<void> {@@ -38,22 +135,101 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
38135 });39136 }40137 138+function watchForUpdate(registration: ServiceWorkerRegistration) {139+ const exposeWaitingWorker = (worker: ServiceWorker) => {140+ waitingWorker = worker;141+ update({ worker: "update-available" });142+ };143+144+ if (registration.waiting && navigator.serviceWorker.controller) {145+ exposeWaitingWorker(registration.waiting);146+ }147+ registration.addEventListener("updatefound", () => {148+ const worker = registration.installing;149+ if (!worker) return;150+ worker.addEventListener("statechange", () => {151+ if (worker.state === "installed" && navigator.serviceWorker.controller) {152+ exposeWaitingWorker(registration.waiting ?? worker);153+ }154+ if (worker.state === "redundant") {155+ update({156+ worker: "failed",157+ failure: { code: "installation", message: "service worker installation failed" },158+ });159+ }160+ });161+ });162+}163+164+function watchInstallExperience() {165+ update({ install: currentInstallExperience() });166+ globalThis.addEventListener("beforeinstallprompt", (event) => {167+ event.preventDefault();168+ deferredInstallPrompt = event as InstallPromptEvent;169+ update({ install: "available" });170+ });171+ globalThis.addEventListener("appinstalled", () => {172+ deferredInstallPrompt = undefined;173+ update({ install: "installed" });174+ });175+}176+177+function watchWorkerMessages() {178+ navigator.serviceWorker.addEventListener("message", (event) => {179+ const message = event.data as {180+ type?: string;181+ code?: PwaFailureCode;182+ message?: string;183+ };184+ if (185+ message?.type !== "LOFI_PWA_FAILURE" ||186+ !["precache", "runtime-cache"].includes(message.code ?? "")187+ ) return;188+ update({189+ worker: "failed",190+ failure: {191+ code: message.code as PwaFailureCode,192+ message: message.message || "service worker cache operation failed",193+ },194+ });195+ });196+}197+41198 export function registerProductionServiceWorker(): void {199+ if (initialized || typeof document === "undefined") return;200+ initialized = true;201+ watchInstallExperience();42202 if (!import.meta.env.PROD) {43- setState("development-disabled");203+ update({ worker: "development-disabled" });44204 return;45205 }46206 if (!("serviceWorker" in navigator)) {47- setState("unsupported");207+ update({ worker: "unsupported" });48208 return;49209 }50- setState("registering");210+ update({ worker: "registering" });211+ watchWorkerMessages();212+ navigator.serviceWorker.addEventListener("controllerchange", () => {213+ if (reloadForUpdate) location.reload();214+ });51215 const workerUrl = new URL("./sw.js", document.baseURI);52216 void (async () => {53- const registration = await navigator.serviceWorker.register(workerUrl);54- const worker = registration.installing ?? registration.waiting ?? registration.active;217+ const registration = await navigator.serviceWorker.register(workerUrl, {218+ scope: new URL("./", document.baseURI).pathname,219+ });220+ watchForUpdate(registration);221+ if (registration.waiting && navigator.serviceWorker.controller) return;222+ const worker = registration.installing ?? registration.active ?? registration.waiting;55223 if (!worker) throw new Error("service worker registration has no worker");56224 await waitForActivation(worker);57- setState("ready");58- })().catch(() => setState("failed"));225+ update({ worker: "ready", failure: undefined });226+ })().catch((error) => {227+ update({228+ worker: "failed",229+ failure: {230+ code: "registration",231+ message: error instanceof Error ? error.message : "service worker registration failed",232+ },233+ });234+ });59235 }@@ -1,4 +1,4 @@
1-import { waitForActivation } from "./pwa.ts";1+import { classifyInstallExperience, waitForActivation } from "./pwa.ts";22 33 const test = (globalThis as unknown as {44 Deno: { test(name: string, body: () => void | Promise<void>): void };@@ -46,3 +46,41 @@ test("service worker activation rejects a redundant install", async () => {
4646 },4747 );4848 });49+50+test("install experience recognizes standalone display modes", () => {51+ const state = classifyInstallExperience({52+ displayModeStandalone: true,53+ navigatorStandalone: false,54+ platform: "Linux armv8l",55+ maxTouchPoints: 5,56+ });57+ if (state !== "installed") throw new Error(`expected installed, received ${state}`);58+});59+60+test("install experience gives iOS manual guidance without exposing a prompt event", () => {61+ const iphone = classifyInstallExperience({62+ displayModeStandalone: false,63+ navigatorStandalone: false,64+ platform: "iPhone",65+ maxTouchPoints: 5,66+ });67+ const ipadDesktopMode = classifyInstallExperience({68+ displayModeStandalone: false,69+ navigatorStandalone: false,70+ platform: "MacIntel",71+ maxTouchPoints: 5,72+ });73+ if (iphone !== "manual-ios" || ipadDesktopMode !== "manual-ios") {74+ throw new Error(`expected iOS guidance, received ${iphone} and ${ipadDesktopMode}`);75+ }76+});77+78+test("install experience degrades to browser mode when no install path is available", () => {79+ const state = classifyInstallExperience({80+ displayModeStandalone: false,81+ navigatorStandalone: false,82+ platform: "MacIntel",83+ maxTouchPoints: 0,84+ });85+ if (state !== "unavailable") throw new Error(`expected unavailable, received ${state}`);86+});@@ -26,8 +26,11 @@ export const STARTER_FILES: readonly string[] = [
2626 "src/_lofi/diagnostics.ts",2727 "src/_lofi/device-capabilities.ts",2828 "src/_lofi/device-capabilities_test.ts",29+ "src/_lofi/foreground-recovery.ts",30+ "src/_lofi/foreground-recovery_test.ts",2931 "src/_lofi/inspector.ts",3032 "src/_lofi/inspector_test.ts",33+ "src/_lofi/lifecycle.ts",3134 "src/_lofi/probe.ts",3235 "src/_lofi/pwa.ts",3336 "src/_lofi/pwa_test.ts",@@ -5,19 +5,22 @@
55 "deno.json": "679b5f2a8da16e060c2423ae159af5fba9a60d4435b5a3696425978010cbd2b7",66 "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",77 "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",8- "public/sw.js": "d816161fd5a35f2c477ca72eaa5ba58eb8d94795a4399d29a19838e886dafa31",8+ "public/sw.js": "6fa9f4c4b5ef7baabd48256a91520c766a4ffaf75c261686384d6d13e663d0c2",99 "README.md": "6a116909a8e9a92a4d48932d2d9672882171cfe1eb1d9d7caa8d36a6e4af8348",10- "src/_lofi/boot.ts": "be739dbb65a963711f47c1ac801fda3afe3b32fb66b70d0a4c3552022adad6ad",10+ "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",1111 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",12- "src/_lofi/device-capabilities_test.ts": "0eece375a0645e915294c7647cba4666e992a8a471cb636cf889bcd323a65ee5",13- "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",12+ "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",13+ "src/_lofi/device-capabilities.ts": "6911fbe8ce93cdd79531982fdafd505202141985baec874e4dd965b02e6115d3",1414 "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",1515 "src/_lofi/diagnostics.ts": "abb2a5f8e4d6b8ab1a86d475db6c374aecf4580b534ba9d7d850e1dbb29bb7f8",16- "src/_lofi/inspector_test.ts": "05de6636a3ff5ff7f918f989ac919cde653fafd81cbd58c8f0c371badb554a07",17- "src/_lofi/inspector.ts": "19fbe4f2d300ca4886ad90f16f6539cc0ad04ba1f38ac28bd9dec269c3f872bd",18- "src/_lofi/probe.ts": "9bb284f4543e631acbfb22bb661b5cfc0798d561b0e95ee09796b36ebb5865b0",19- "src/_lofi/pwa_test.ts": "092d9ff442d82086a03cf45c6ed0256ab0f1ce8db42fe7e636d4b93d6ebae63e",20- "src/_lofi/pwa.ts": "de48d0653890eb735067a161e907f8c957cb11719ff99e5faf4394efb67cf681",16+ "src/_lofi/foreground-recovery_test.ts": "a5081aecaa70b71c63d721a0875f4d2b3898a49879eb8045c8e74baf0d867444",17+ "src/_lofi/foreground-recovery.ts": "a5ed7a65b9454770de62a4ba8cdac7b0d1a867ec3c703049d2c103791c4d0049",18+ "src/_lofi/inspector_test.ts": "895896572059b37bb8d320953839106efa3a8caa55754b9fbbab781303525a58",19+ "src/_lofi/inspector.ts": "c9f646b17954e07ecece69f9cbef2e3c4a70cd12f62a549220a3f34bee477cf2",20+ "src/_lofi/lifecycle.ts": "0652ce9d4564b830771522986e9d3d6a2afe0849661b43f2d8b4e47ff16c1a1d",21+ "src/_lofi/probe.ts": "818cd9546c900ba482fc6ae8420a5a6e47b8f106ac56dc89c3c647a31aa1c704",22+ "src/_lofi/pwa_test.ts": "bc9c04ff61e2f5a0dfd4601fbab929aa3815f4b174fad600d68b1f74f070d346",23+ "src/_lofi/pwa.ts": "9ee6958a63857bb87f9afb58595eb138ef0865fd7c8a4014ac8f90f36813f39a",2124 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2225 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",2326 "src/_lofi/runtime.ts": "db204517acc708f5145f48ed5cbd1ab95c01e35ddedb09dc0ba9617ea8bc1f79",