WebKit gives an installed web app its own storage container: Add to Home
Screen copies cookies but not OPFS, IndexedDB, or localStorage, so local
data created in a browser tab before installing is invisible in the
installed app. The starter's default install UI walked users straight
into that fork with no warning and no explanation afterwards.
The new storage-fork guard covers both sides of the boundary. In a
browser tab it recognizes local-only data (a persistent marker set on
observed write activity, plus a one-shot bounded row probe for data that
predates the guard) and, while any exists unsynced, the iOS install
guidance in PwaActions leads with a warning to back up or sync first.
It also maintains a per-app flag cookie — the one signal WebKit copies
at install — refreshed each boot to outlive Safari's seven-day cap on
script-set cookies and deleted once sync is elected. A standalone launch
into a container that has never held a lofi key but inherited the flag
detects the fork and renders a dismissible notice naming where the data
lives and the recovery path; `pwa: { forkNotice: "none" }` suppresses it
for apps that render their own from the new useStorageFork hook.
DeviceStatus reports the verdict in its PWA section. Chromium installs
share the profile's storage and never warn; per-browser desktop
isolation is documented instead.
The guard arms as the first statement of bootLofi so its freshness
snapshot precedes any storage writes, stays inert in development, and
tolerates blocked storage and cookies. Deployment and troubleshooting
docs cover the container fork, the mechanism's limits, and the manual
recovery steps.- commit
- bb643da35cf2bff48319b2c3589e530d99b4cdbf
- parent
- 73c6045ce905ca28a5fecb377ce15f7b1db38362
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 34 | ++ |
| M | docs/ | 21 | + |
| M | package/ | 2 | + |
| M | package/ | 12 | + |
| M | package/ | 14 | +- |
| M | package/ | 54 | ++++- |
| M | package/ | 8 | +- |
| A | package/ | 42 | +++ |
| M | package/ | 7 | + |
| M | package/ | 5 | + |
| M | package/ | 11 | + |
| M | package/ | 18 | + |
| A | package/ | 442 | ++++++++++++++++++++++++++++++ |
| A | package/ | 311 | +++++++++++++++++++++ |
@@ -227,6 +227,40 @@ A runtime-cache write error is best-effort and leaves the active worker ready. R
227227 required precache, and activation failures remain worker failures; update-check failures leave the228228 current worker running and retry on a later foreground signal.229229 230+### Installed apps get their own storage container231+232+On iOS, iPadOS, and macOS, **Add to Home Screen** and **Add to Dock** create the installed web app233+in a separate storage container. WebKit copies cookies at install time; OPFS, IndexedDB, and234+localStorage stay behind in the browser. Local data created in a Safari tab before installing is235+therefore not visible in the installed app — the data is safe in Safari's container, but the236+installed app starts empty. Desktop installs isolate differently: a Chromium installed app shares237+the storage of the browser profile that installed it (installing does not fork data), but each238+browser keeps its own copy, so a site used in Chrome and installed from Edge holds two independent239+local instances.240+241+The framework covers the WebKit fork on both sides of the boundary:242+243+- **Before installing.** While a browser context holds local-only data (writes that do not replicate244+ because sync has not been elected), the iOS install guidance in `PwaActions` leads with a warning:245+ installing opens a fresh copy, so back up or turn on sync first and restore in the installed app.246+- **After installing.** While data is at risk, the runtime maintains a per-app flag cookie, the one247+ signal WebKit copies at install. A standalone launch whose local storage has never been touched248+ but which inherited that flag detects the fork and renders a fixed notice249+ (`.lofi-storage-fork-banner`) naming where the data lives and the recovery path. Dismissing the250+ notice clears the flag in the installed container and starts fresh.251+252+Apps can restyle the notice via its class, or replace both surfaces entirely: suppress the default253+notice with `pwa: { forkNotice: "none" }` in `defineLofiApp` and render a custom surface from the254+`useStorageFork` hook in `@nzip/lofi/preact` (states: `unarmed`, `idle`, `browser-data-at-risk`,255+`fork-detected`). The `DeviceStatus` panel reports the same verdict in its PWA section.256+257+Two limits are inherent to the mechanism. The cookie copy is one-time at install, so nothing the258+browser does afterwards reaches an already-installed container. And Safari caps script-set cookies259+at seven days; the runtime refreshes the flag on every boot while data is at risk, but a user who260+last visited the browser tab more than a week before installing gets no post-install notice. The261+pre-install warning does not depend on the cookie and always applies. The recovery steps live in262+[Troubleshooting](troubleshooting.md#the-installed-app-opened-empty-after-add-to-home-screen).263+230264 ### Schema compatibility and read-only mode231265 232266 The app shell and the local data version independently: a browser can hold an old cached shell next@@ -137,6 +137,27 @@ display it. `no_schema` means the store has never been provisioned for this app
137137 never provisions or repairs a store on its own, and for non-ticket sinks the row stays138138 `not checked`.139139 140+## The installed app opened empty after Add to Home Screen141+142+WebKit gives an installed web app its own storage container: cookies are copied at install time, but143+OPFS, IndexedDB, and localStorage are not. Data created in a Safari tab before installing stays in144+Safari's container — it is not lost, and the installed app cannot see it. When the runtime detects145+this fork it renders a notice (`.lofi-storage-fork-banner`), and the `DeviceStatus` panel's146+**Storage container** row reads `fresh install — previous data is in the browser`.147+148+To move the data:149+150+1. Open the site in Safari (the browser, not the installed icon). The original data is there.151+2. In the account panel, turn on sync, or back up the account (reveal the recovery phrase or create152+ a passkey backup) where a sync location exists.153+3. Return to the installed app and restore the account there; enrolled sync replays the data.154+4. Once the data appears, dismiss the notice.155+156+Dismissing without restoring starts the installed app fresh; the Safari copy remains until Safari's157+site data for the origin is cleared. If no notice appeared (the flag cookie expires after seven days158+without a browser visit), the same steps apply. Background on the container fork and its limits is159+in [Deployment](deployment.md#installed-apps-get-their-own-storage-container).160+140161 ## Recovery does not restore a recent item141162 142163 The phrase restores account authority, not unsynced device storage. Data returns only if it reached@@ -51,6 +51,7 @@ export const runtimeFiles = [
5151 "schema-compat.ts",5252 "session.ts",5353 "startup-recovery.ts",54+ "storage-fork.ts",5455 "store-status.ts",5556 "table-mutations.ts",5657 "table-store.ts",@@ -93,6 +94,7 @@ export const preactFiles = [
9394 "use-boot-progress.ts",9495 "use-device-capabilities.ts",9596 "use-schema-compat.ts",97+ "use-storage-fork.ts",9698 "write-hooks.ts",9799 ] as const;98100 @@ -11,6 +11,8 @@ import {
1111 subscribeRuntimeDiagnostics,1212 } from "../runtime/runtime.ts";1313 import { describeSchemaCompat } from "../runtime/schema-compat.ts";14+import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork.ts";15+import { useStorageFork } from "./use-storage-fork.ts";1416 import { readSession, type Session } from "../runtime/session.ts";1517 import { describeStoreStatus } from "../runtime/store-status.ts";1618 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";@@ -48,6 +50,7 @@ const available = (present: boolean) => (present ? "available" : "missing");
4850 */4951 export default function DeviceStatus(): VNode {5052 const { report, requestPersistence } = useDeviceCapabilities();53+ const fork = useStorageFork();5154 const [pwa, setPwa] = useState<PwaState>(getPwaState());5255 const [session, setSession] = useState<Session | null>(null);5356 const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());@@ -171,11 +174,20 @@ export default function DeviceStatus(): VNode {
171174 label="Schema compatibility"172175 value={describeSchemaCompat(runtimeDiagnostics.schemaCompat)}173176 />177+ <Row label="Storage container" value={describeStorageFork(fork)} />174178 </dl>175179 {(runtimeDiagnostics.schemaCompat.state === "data-ahead" ||176180 runtimeDiagnostics.schemaCompat.state === "updating") && (177181 <p>{runtimeDiagnostics.schemaCompat.message}</p>178182 )}183+ {fork.state === "fork-detected" && (184+ <>185+ <p>{fork.message}</p>186+ <button type="button" onClick={() => dismissStorageFork()}>187+ Dismiss188+ </button>189+ </>190+ )}179191 <PwaActions title="Install & updates" />180192 </div>181193 </section>@@ -6,6 +6,7 @@ import {
66 pwaFailureMessage,77 type PwaState,88 } from "../runtime/pwa.ts";9+import { type StorageForkSurface, useStorageFork } from "./use-storage-fork.ts";910 1011 export { pwaFailureMessage } from "../runtime/pwa.ts";1112 export type { PwaController, PwaFailureCode, PwaState, PwaUpdateState } from "../runtime/pwa.ts";@@ -30,10 +31,12 @@ export function usePwaState(controller: PwaController = pwaController): PwaState
3031 return state;3132 }3233 33-/** Optional controller and heading text for {@link PwaActions}. */34+/** Optional controller, fork guard, and heading text for {@link PwaActions}. */3435 export interface PwaActionsProps {3536 /** Controller to observe; defaults to the package-wide PWA controller. */3637 readonly controller?: PwaController;38+ /** Fork guard to observe; defaults to the package-wide guard. */39+ readonly fork?: StorageForkSurface;3740 /** Heading rendered above install or update actions. */3841 readonly title?: string;3942 }@@ -53,9 +56,11 @@ export interface PwaActionsProps {
5356 */5457 export function PwaActions({5558 controller = pwaController,59+ fork,5660 title = "Install & updates",5761 }: PwaActionsProps): VNode | null {5862 const state = usePwaState(controller);63+ const forkState = useStorageFork(fork);5964 const visible = state.install === "available" || state.install === "manual-ios" ||6065 state.install === "manual-browser" || state.install === "unsupported" ||6166 state.install === "accepted" || state.install === "dismissed" ||@@ -81,6 +86,13 @@ export function PwaActions({
8186 )}8287 {state.install === "manual-ios" && (8388 <div class="pwa-ios-guidance">89+ {forkState.state === "browser-data-at-risk" && (90+ <p class="pwa-fork-warning" role="alert">91+ Installing opens a fresh, empty copy of this app — the data you created here stays in92+ Safari and is not carried over. Turn on sync or back up your account first, then93+ restore it in the installed app.94+ </p>95+ )}8496 <p>Install this app on your iPhone or iPad:</p>8597 <ol>8698 <li>Open the Share menu.</li>@@ -1,6 +1,8 @@
11 import { render } from "npm:preact-render-to-string@6.7.0";22 import type { PwaController, PwaState } from "../runtime/pwa.ts";3+import type { StorageForkState } from "../runtime/storage-fork.ts";34 import { PwaActions, pwaFailureMessage } from "./PwaActions.tsx";5+import type { StorageForkSurface } from "./use-storage-fork.ts";46 57 function controller(state: PwaState): PwaController {68 return {@@ -16,8 +18,23 @@ function controller(state: PwaState): PwaController {
1618 };1719 }1820 19-function renderState(state: PwaState): string {20- return render(<PwaActions controller={controller(state)} />);21+function fork(state: StorageForkState): StorageForkSurface {22+ return {23+ getState: () => state,24+ subscribe(subscriber) {25+ subscriber(state);26+ return () => undefined;27+ },28+ };29+}30+31+function renderState(state: PwaState, forkState?: StorageForkState): string {32+ return render(33+ <PwaActions34+ controller={controller(state)}35+ fork={forkState ? fork(forkState) : undefined}36+ />,37+ );2138 }2239 2340 Deno.test("PwaActions renders a Chromium action only while the prompt is available", () => {@@ -38,6 +55,39 @@ Deno.test("PwaActions renders complete iOS Add to Home Screen guidance", () => {
3855 if (/unavailable in (?:the )?EU/i.test(html)) throw new Error("obsolete EU guidance returned");3956 });4057 58+Deno.test("PwaActions leads iOS guidance with the fork warning while data is at risk", () => {59+ const html = renderState(60+ { worker: "ready", install: "manual-ios", update: "idle" },61+ { state: "browser-data-at-risk" },62+ );63+ const warning = html.indexOf("stays in Safari and is not carried over");64+ const steps = html.indexOf("Open the Share menu.");65+ if (warning === -1) throw new Error("the iOS fork warning was omitted");66+ if (steps === -1 || warning > steps) {67+ throw new Error("the fork warning does not precede the install steps");68+ }69+});70+71+Deno.test("PwaActions omits the fork warning without local-only data", () => {72+ const html = renderState(73+ { worker: "ready", install: "manual-ios", update: "idle" },74+ { state: "idle" },75+ );76+ if (html.includes("pwa-fork-warning")) throw new Error("the fork warning rendered without risk");77+});78+79+Deno.test("PwaActions never warns about forking outside the iOS branch", () => {80+ for (const install of ["available", "manual-browser"] as const) {81+ const html = renderState(82+ { worker: "ready", install, update: "idle" },83+ { state: "browser-data-at-risk" },84+ );85+ if (html.includes("pwa-fork-warning")) {86+ throw new Error(`the fork warning rendered for ${install}, which does not fork storage`);87+ }88+ }89+});90+4191 Deno.test("PwaActions renders truthful generic manual-install guidance", () => {4292 const html = renderState({ worker: "ready", install: "manual-browser", update: "idle" });4393 if (!html.includes("No in-page install button is available right now")) {@@ -4,7 +4,8 @@
44 * per-write sync lifecycle ({ useWrite}, { usePendingWrites},55 * { useSyncStatus}), first-load progress ({ useBootProgress}), and66 * the schema-compatibility gate7- * ({ useSchemaCompat}), plus package-owned example components7+ * ({ useSchemaCompat}), and the storage-container fork guard8+ * ({ useStorageFork}), plus package-owned example components89 * ({ DeviceStatus}, { PwaActions}, { RuntimeRecovery},910 * { TicketEnrollForm}) that application layouts may compose or replace1011 * with UI built on the same public runtime APIs.@@ -43,6 +44,11 @@ export {
4344 type SchemaCompatState,4445 useSchemaCompat,4546 } from "./use-schema-compat.ts";47+export {48+ type StorageForkState,49+ type StorageForkSurface,50+ useStorageFork,51+} from "./use-storage-fork.ts";4652 export { type BootProgress, type BootProgressPhase, useBootProgress } from "./use-boot-progress.ts";4753 export {4854 type DeviceCapabilitiesHook,@@ -0,0 +1,42 @@
1+/**2+ * Preact binding for the storage-container fork guard: the live fork state,3+ * for apps that render their own surface in place of the framework's default4+ * fork notice.5+ *6+ * @module7+ */8+9+import { useEffect, useState } from "preact/hooks";10+import {11+ type StorageForkGuard,12+ storageForkGuard,13+ type StorageForkState,14+} from "../runtime/storage-fork.ts";15+16+export type { StorageForkState } from "../runtime/storage-fork.ts";17+18+/** The guard surface a component observes; defaults to the shared guard. */19+export type StorageForkSurface = Pick<StorageForkGuard, "getState" | "subscribe">;20+21+/**22+ * Subscribes a Preact component to the storage-container fork guard, so apps23+ * can render their own install warning or fork notice in place of the24+ * framework default (suppress the default with `pwa: { forkNotice: "none" }`25+ * in `defineLofiApp`).26+ *27+ * @example28+ * ```tsx29+ * import { useStorageFork } from "@nzip/lofi/preact";30+ *31+ * const fork = useStorageFork();32+ * if (fork.state === "fork-detected") return <ForkNotice message={fork.message} />;33+ * ```34+ *35+ * @param guard The guard to observe; defaults to the package-wide guard.36+ * @returns The current fork state, kept live via subscription.37+ */38+export function useStorageFork(guard: StorageForkSurface = storageForkGuard): StorageForkState {39+ const [state, setState] = useState<StorageForkState>(guard.getState());40+ useEffect(() => guard.subscribe(setState), [guard]);41+ return state;42+}@@ -43,6 +43,13 @@ export type LofiAppConfig<Schema = unknown> = {
4343 * keeps the document read-only and asks the user to reload.4444 */4545 staleTabs?: "reload" | "prompt";46+ /**47+ * `default` renders the framework's minimal notice when an installed48+ * WebKit app starts in a fresh storage container while local data exists49+ * in the browser; `none` suppresses it for apps that render their own50+ * from `useStorageFork`.51+ */52+ forkNotice?: "default" | "none";4653 };4754 sync: { adapter: "jazz" };4855 repositoryUrl?: string;@@ -8,6 +8,7 @@ import {
88 resolvePwaResources,99 } from "./pwa.ts";1010 import { mountDefaultCompatBanner, schemaCompatGate } from "./schema-compat.ts";11+import { mountDefaultForkNotice, storageForkGuard } from "./storage-fork.ts";1112 import { configureUpgradeCoordinator } from "./upgrade-coordination.ts";1213 1314 let booted = false;@@ -61,6 +62,10 @@ function startCompatibilityGate(): void {
6162 export async function bootLofi(): Promise<void> {6263 if (booted || typeof document === "undefined") return;6364 booted = true;65+ // The fork guard snapshots container freshness from localStorage, so it66+ // must run before any other module stores a key there.67+ storageForkGuard.start();68+ mountDefaultForkNotice();6469 // The sink envelope must be open before lifecycle.ts reads the sync6570 // location at import time. A restore failure degrades to local-only; it6671 // must never brick boot.@@ -94,6 +94,17 @@ export {
9494 type SchemaCompatState,9595 subscribeSchemaCompat,9696 } from "./schema-compat.ts";97+export {98+ createStorageForkGuard,99+ describeStorageFork,100+ dismissStorageFork,101+ getStorageForkState,102+ type StorageForkGuard,103+ storageForkGuard,104+ type StorageForkGuardDependencies,105+ type StorageForkState,106+ subscribeStorageFork,107+} from "./storage-fork.ts";97108 export {98109 acquireLiveQuery,99110 type LiveQueryLease,@@ -141,6 +141,24 @@ async function awaitLocalReady(db: Db): Promise<void> {
141141 await db.all((query.limit?.(1) ?? query) as never, { tier: "local" });142142 }143143 144+/**145+ * Whether any declared table holds at least one locally persisted row.146+ * Bounded at one row per table, for package modules that need an147+ * existence answer without materializing data.148+ */149+export async function hasAnyLocalRows(): Promise<boolean> {150+ const { db } = await getRuntime();151+ for (const table of runtimeTables()) {152+ const query = table.where({}) as { limit?: (n: number) => unknown };153+ const rows = await db.all(154+ (query.limit?.(1) ?? query) as never,155+ { tier: "local" },156+ ) as unknown[];157+ if (rows.length > 0) return true;158+ }159+ return false;160+}161+144162 async function migrateLocalRows(145163 state: RuntimeSlot,146164 db: Db,@@ -0,0 +1,442 @@
1+/// <reference path="./env.d.ts" />2+/**3+ * The storage-container fork guard for installed web apps.4+ *5+ * WebKit gives an installed web app (Add to Home Screen on iOS and iPadOS,6+ * Add to Dock on macOS) its own storage container: cookies are copied at7+ * install time, but OPFS, IndexedDB, and localStorage are not. Local data8+ * created in a browser tab before installing therefore stays in the browser's9+ * container, and the installed app starts empty. Chromium installed apps share10+ * the installing profile's storage and do not fork.11+ *12+ * The guard covers both sides of that boundary. In a browser tab it watches13+ * for local-only data — writes that do not replicate because the user has not14+ * elected sync — and, while any exists, maintains a per-app flag cookie, the15+ * one signal that survives installation. In a standalone launch whose local16+ * storage has never been touched, that inherited cookie identifies the fork,17+ * and the guard surfaces a notice naming where the data lives and how to move18+ * it: back up or sync in the browser, then restore in the installed app.19+ *20+ * Safari caps script-set cookies at seven days, so the flag is refreshed on21+ * every boot while data is at risk; a long gap between the last browser visit22+ * and installation loses the flag and, with it, the post-install notice. The23+ * pre-install warning in the install guidance does not depend on the cookie.24+ *25+ * Zero configuration is safe: in development the guard is inert, and the26+ * framework renders a minimal default notice that apps may replace via the27+ * optional `pwa` block of `defineLofiApp` and the `useStorageFork` Preact28+ * hook.29+ *30+ * @module31+ */32+33+import { getLofiApp } from "./app.ts";34+import { syncing } from "./config.ts";35+import { anchorAppId } from "./data-sink.ts";36+import type { RuntimeDiagnostics } from "./diagnostics.ts";37+import type { InstallEnvironment } from "./pwa.ts";38+39+/** The storage-container fork state exposed through the guard and the Preact hook. */40+export type StorageForkState =41+ /** No verdict: development mode, or the guard has not started. */42+ | { state: "unarmed"; reason: "inactive" | "development" }43+ /** Nothing to warn about: no local-only data, or writes replicate. */44+ | { state: "idle" }45+ /** This browsing context holds local-only data; installing on iOS forks it. */46+ | { state: "browser-data-at-risk" }47+ /** A standalone launch into a fresh container while the flag cookie is present. */48+ | { state: "fork-detected"; message: string };49+50+const forkDetectedMessage =51+ "This installed app starts with its own empty storage — the data you created before " +52+ "installing is still in Safari. Open the site in Safari, turn on sync or back up your " +53+ "account there, then restore it here. Dismiss this to start fresh.";54+55+/** The diagnostics fields the guard reads to recognize real write activity. */56+export type StorageForkDiagnostics = Pick<57+ RuntimeDiagnostics,58+ "storageState" | "localWaitCalls" | "journaledPendingWrites" | "lastWriteDurability"59+>;60+61+/** The diagnostics feed the guard observes; the runtime supplies the default. */62+export type StorageForkDiagnosticsSurface = {63+ /** The current diagnostics snapshot. */64+ get(): StorageForkDiagnostics;65+ /** Subscribes to diagnostics changes; returns an unsubscribe function. */66+ subscribe(listener: () => void): () => void;67+};68+69+/** Injectable browser surfaces used to test the guard deterministically. */70+export type StorageForkGuardDependencies = {71+ readonly production?: () => boolean;72+ readonly appId?: () => string;73+ readonly storage?: () => Pick<Storage, "getItem" | "setItem"> | undefined;74+ /** Every localStorage key in this container, for the freshness snapshot. */75+ readonly storageKeys?: () => readonly string[];76+ readonly readCookies?: () => string;77+ readonly writeCookie?: (cookie: string) => void;78+ /** The cookie `Path` attribute; defaults to the deployment base path. */79+ readonly cookiePath?: () => string;80+ readonly secureContext?: () => boolean;81+ readonly environment?: () => Pick<82+ InstallEnvironment,83+ "displayModeStandalone" | "navigatorStandalone"84+ >;85+ readonly syncing?: () => boolean;86+ /** The write-activity feed; defaults to the runtime's diagnostics, loaded lazily. */87+ readonly diagnostics?: () => StorageForkDiagnosticsSurface;88+ /** Whether any declared table holds a locally persisted row (pre-guard data). */89+ readonly probeLocalRows?: () => Promise<boolean>;90+ /** Target for cross-tab `storage` events; defaults to the window. */91+ readonly storageEvents?: () => EventTarget | undefined;92+};93+94+/** The storage-container fork guard for one browsing context. */95+export type StorageForkGuard = {96+ /** Starts the guard once; later calls are no-ops. */97+ start(): void;98+ /** The current fork state snapshot. */99+ getState(): StorageForkState;100+ /** Subscribes to state changes; the listener runs immediately. */101+ subscribe(listener: (state: StorageForkState) => void): () => void;102+ /** Acknowledges a detected fork: clears the flag cookie and settles idle. */103+ dismissFork(): void;104+};105+106+function defaultStorageKeys(): readonly string[] {107+ try {108+ if (typeof localStorage === "undefined") return [];109+ const keys: string[] = [];110+ for (let index = 0; index < localStorage.length; index += 1) {111+ const key = localStorage.key(index);112+ if (key !== null) keys.push(key);113+ }114+ return keys;115+ } catch {116+ return [];117+ }118+}119+120+function defaultCookiePath(): string {121+ try {122+ const base = new URL(import.meta.env.BASE_URL, document.location.origin);123+ return base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;124+ } catch {125+ return "/";126+ }127+}128+129+function defaultEnvironment(): Pick<130+ InstallEnvironment,131+ "displayModeStandalone" | "navigatorStandalone"132+> {133+ try {134+ return {135+ displayModeStandalone: matchMedia("(display-mode: standalone)").matches,136+ navigatorStandalone: (navigator as Navigator & { standalone?: boolean }).standalone === true,137+ };138+ } catch {139+ return { displayModeStandalone: false, navigatorStandalone: false };140+ }141+}142+143+/** Creates an isolated guard, primarily for tests; boot uses the shared one. */144+export function createStorageForkGuard(145+ dependencies: StorageForkGuardDependencies = {},146+): StorageForkGuard {147+ const subscribers = new Set<(state: StorageForkState) => void>();148+ let state: StorageForkState = { state: "unarmed", reason: "inactive" };149+ let started = false;150+ let probed = false;151+ // In-memory marker fallback so a private-mode storage failure still warns152+ // for the current document.153+ let markerMemory = false;154+ let lastCookieVerdict: boolean | null = null;155+156+ const appId = () => dependencies.appId?.() ?? anchorAppId;157+ const storage = () => {158+ if (dependencies.storage) return dependencies.storage();159+ try {160+ return typeof localStorage === "undefined" ? undefined : localStorage;161+ } catch {162+ return undefined;163+ }164+ };165+ const storageKeys = () => {166+ try {167+ return dependencies.storageKeys?.() ?? defaultStorageKeys();168+ } catch {169+ return [];170+ }171+ };172+ const readCookies = () => {173+ try {174+ if (dependencies.readCookies) return dependencies.readCookies();175+ return typeof document === "undefined" ? "" : document.cookie;176+ } catch {177+ return "";178+ }179+ };180+ const writeCookie = (cookie: string) => {181+ try {182+ if (dependencies.writeCookie) dependencies.writeCookie(cookie);183+ else if (typeof document !== "undefined") document.cookie = cookie;184+ } catch {185+ // A blocked cookie write must not affect a working runtime.186+ }187+ };188+ const cookiePath = () => dependencies.cookiePath?.() ?? defaultCookiePath();189+ const secureContext = () => dependencies.secureContext?.() ?? globalThis.isSecureContext === true;190+ const environment = () => {191+ try {192+ return dependencies.environment?.() ?? defaultEnvironment();193+ } catch {194+ return { displayModeStandalone: false, navigatorStandalone: false };195+ }196+ };197+ const syncingNow = () => {198+ try {199+ return dependencies.syncing?.() ?? syncing();200+ } catch {201+ return false;202+ }203+ };204+205+ const markerKey = () => `lofi:local-data:${appId()}`;206+ const cookieName = () => `lofi-fork-${appId().replace(/[^A-Za-z0-9_-]/g, "_")}`;207+208+ const publish = (next: StorageForkState) => {209+ state = next;210+ for (const subscriber of subscribers) subscriber(state);211+ };212+213+ const hasMarker = () => {214+ if (markerMemory) return true;215+ try {216+ return storage()?.getItem(markerKey()) === "1";217+ } catch {218+ return false;219+ }220+ };221+222+ const setMarker = () => {223+ markerMemory = true;224+ try {225+ storage()?.setItem(markerKey(), "1");226+ } catch {227+ // The in-memory fallback still covers this document.228+ }229+ };230+231+ const cookiePresent = () =>232+ readCookies().split(";").some((part) => part.trim().startsWith(`${cookieName()}=`));233+234+ const cookieAttributes = () =>235+ `Path=${cookiePath()}; SameSite=Lax${secureContext() ? "; Secure" : ""}`;236+237+ const maintainCookie = (atRisk: boolean) => {238+ if (lastCookieVerdict === atRisk) return;239+ lastCookieVerdict = atRisk;240+ writeCookie(241+ atRisk242+ ? `${cookieName()}=1; ${cookieAttributes()}; Max-Age=31536000`243+ : `${cookieName()}=; ${cookieAttributes()}; Max-Age=0`,244+ );245+ };246+247+ const evaluate = () => {248+ // A detected fork stands until the user dismisses it: cookie maintenance249+ // must not clear the inherited flag before the notice has been seen.250+ if (state.state === "fork-detected") return;251+ const atRisk = hasMarker() && !syncingNow();252+ maintainCookie(atRisk);253+ publish(atRisk ? { state: "browser-data-at-risk" } : { state: "idle" });254+ };255+256+ const observeDiagnostics = (257+ surface: StorageForkDiagnosticsSurface,258+ probeLocalRows: () => Promise<boolean>,259+ ) => {260+ const check = () => {261+ if (hasMarker()) {262+ evaluate();263+ return;264+ }265+ const diagnostics = surface.get();266+ const wrote = diagnostics.localWaitCalls > 0 ||267+ diagnostics.journaledPendingWrites > 0 ||268+ diagnostics.lastWriteDurability === "local" ||269+ diagnostics.lastWriteDurability === "global";270+ if (wrote) {271+ setMarker();272+ evaluate();273+ return;274+ }275+ // Data written before the guard existed leaves no marker; one bounded276+ // probe answers for it. Waiting for the open driver guarantees the277+ // probe reuses the runtime rather than causing account creation.278+ if (!probed && diagnostics.storageState === "persistent-driver-open") {279+ probed = true;280+ void probeLocalRows().then((found) => {281+ if (found) {282+ setMarker();283+ evaluate();284+ }285+ }).catch(() => undefined);286+ }287+ };288+ surface.subscribe(check);289+ check();290+ };291+292+ const attachDiagnostics = () => {293+ if (dependencies.diagnostics) {294+ const probe = dependencies.probeLocalRows ?? (() => Promise.resolve(false));295+ observeDiagnostics(dependencies.diagnostics(), probe);296+ return;297+ }298+ void import("./runtime.ts").then((runtime) => {299+ observeDiagnostics(300+ { get: runtime.getRuntimeDiagnostics, subscribe: runtime.subscribeRuntimeDiagnostics },301+ dependencies.probeLocalRows ?? runtime.hasAnyLocalRows,302+ );303+ }).catch(() => undefined);304+ };305+306+ const attachStorageEvents = () => {307+ const target = dependencies.storageEvents308+ ? dependencies.storageEvents()309+ : (typeof window === "undefined" ? undefined : window);310+ // Another tab electing sync or writing first data changes this tab's311+ // verdict in place; every runtime key is suffixed with the app id.312+ target?.addEventListener("storage", (event) => {313+ const key = (event as StorageEvent).key;314+ if (typeof key !== "string" || key.endsWith(`:${appId()}`)) evaluate();315+ });316+ };317+318+ return {319+ start() {320+ if (started) return;321+ started = true;322+ if (!(dependencies.production?.() ?? import.meta.env.PROD)) {323+ publish({ state: "unarmed", reason: "development" });324+ return;325+ }326+ // The freshness snapshot must precede any runtime writes: a container327+ // is fresh only if no lofi key for this app has ever been stored.328+ const fresh = !storageKeys().some((key) =>329+ key.startsWith("lofi:") && key.endsWith(`:${appId()}`)330+ );331+ const standalone = (() => {332+ const env = environment();333+ return env.displayModeStandalone || env.navigatorStandalone;334+ })();335+ attachStorageEvents();336+ if (standalone && fresh && cookiePresent()) {337+ publish({ state: "fork-detected", message: forkDetectedMessage });338+ } else {339+ evaluate();340+ }341+ attachDiagnostics();342+ },343+ getState: () => state,344+ subscribe(listener) {345+ subscribers.add(listener);346+ listener(state);347+ return () => subscribers.delete(listener);348+ },349+ dismissFork() {350+ if (state.state !== "fork-detected") return;351+ state = { state: "idle" };352+ lastCookieVerdict = null;353+ evaluate();354+ },355+ };356+}357+358+/** Shared guard used by boot and the optional Preact bindings. */359+export const storageForkGuard: StorageForkGuard = createStorageForkGuard();360+361+/** Returns the shared guard's current fork state snapshot. */362+export function getStorageForkState(): StorageForkState {363+ return storageForkGuard.getState();364+}365+366+/** Subscribes to shared fork state; returns an unsubscribe function. */367+export function subscribeStorageFork(368+ listener: (state: StorageForkState) => void,369+): () => void {370+ return storageForkGuard.subscribe(listener);371+}372+373+/** Acknowledges a detected fork on the shared guard. */374+export function dismissStorageFork(): void {375+ storageForkGuard.dismissFork();376+}377+378+/** One-line label for diagnostic surfaces (development inspector, DeviceStatus). */379+export function describeStorageFork(state: StorageForkState): string {380+ switch (state.state) {381+ case "unarmed":382+ return state.reason === "development" ? "not checked (development)" : "not checked";383+ case "idle":384+ return "no local-only data at risk";385+ case "browser-data-at-risk":386+ return "local-only data — not backed up";387+ case "fork-detected":388+ return "fresh install — previous data is in the browser";389+ }390+}391+392+// ---------------------------------------------------------------------------393+// Default notice: the zero-configuration surface for a detected fork. A plain394+// DOM element (no UI framework) so it works in any app; authors opt out with395+// `pwa: { forkNotice: "none" }` and render their own from useStorageFork.396+// ---------------------------------------------------------------------------397+398+const noticeClass = "lofi-storage-fork-banner";399+400+function renderDefaultNotice(state: StorageForkState): void {401+ const existing = document.querySelector(`.${noticeClass}`);402+ if (state.state !== "fork-detected") {403+ existing?.remove();404+ return;405+ }406+ const notice = existing ?? document.createElement("div");407+ notice.textContent = "";408+ notice.className = noticeClass;409+ notice.setAttribute("role", "alert");410+ (notice as HTMLElement).style.cssText =411+ "position:fixed;inset:auto 0 0 0;z-index:2147483000;display:flex;gap:0.75rem;" +412+ "align-items:center;justify-content:center;flex-wrap:wrap;padding:0.75rem 1rem;" +413+ "background:#1f2430;color:#fff;font:14px/1.4 system-ui,sans-serif;text-align:center;";414+ const message = document.createElement("span");415+ message.textContent = state.message;416+ notice.append(message);417+ const button = document.createElement("button");418+ button.type = "button";419+ button.textContent = "Dismiss";420+ button.style.cssText =421+ "padding:0.35rem 0.9rem;border:1px solid #fff;border-radius:0.4rem;background:none;" +422+ "color:inherit;font:inherit;cursor:pointer;";423+ button.addEventListener("click", () => storageForkGuard.dismissFork());424+ notice.append(button);425+ if (!existing) document.body.append(notice);426+}427+428+/**429+ * Mounts the framework's minimal fork notice unless the app opted out.430+ * Called once by boot; safe without a defined app (defaults apply).431+ */432+export function mountDefaultForkNotice(): void {433+ if (typeof document === "undefined") return;434+ let preference: "default" | "none" = "default";435+ try {436+ preference = getLofiApp().pwa?.forkNotice ?? "default";437+ } catch {438+ // No app definition yet; the safe default applies.439+ }440+ if (preference === "none") return;441+ storageForkGuard.subscribe(renderDefaultNotice);442+}@@ -0,0 +1,311 @@
1+import {2+ createStorageForkGuard,3+ describeStorageFork,4+ type StorageForkDiagnostics,5+ type StorageForkGuardDependencies,6+ type StorageForkState,7+} from "./storage-fork.ts";8+import { assert } from "./test-assert.ts";9+10+class FakeStorage {11+ readonly values = new Map<string, string>();12+ getItem(key: string): string | null {13+ return this.values.get(key) ?? null;14+ }15+ setItem(key: string, value: string): void {16+ this.values.set(key, value);17+ }18+ keys(): readonly string[] {19+ return [...this.values.keys()];20+ }21+}22+23+class FakeCookieJar {24+ readonly values = new Map<string, string>();25+ lastWritten: string | null = null;26+27+ read(): string {28+ return [...this.values].map(([name, value]) => `${name}=${value}`).join("; ");29+ }30+ write(cookie: string): void {31+ this.lastWritten = cookie;32+ const [pair, ...attributes] = cookie.split(";").map((part) => part.trim());33+ const separator = pair.indexOf("=");34+ const name = pair.slice(0, separator);35+ const maxAge = attributes.find((attribute) => attribute.toLowerCase().startsWith("max-age="));36+ if (maxAge && Number(maxAge.slice("max-age=".length)) <= 0) this.values.delete(name);37+ else this.values.set(name, pair.slice(separator + 1));38+ }39+}40+41+class FakeDiagnostics {42+ #state: StorageForkDiagnostics = {43+ storageState: "persistent-requested",44+ localWaitCalls: 0,45+ journaledPendingWrites: 0,46+ lastWriteDurability: "none",47+ };48+ readonly #subscribers = new Set<() => void>();49+50+ get = (): StorageForkDiagnostics => this.#state;51+ subscribe = (listener: () => void): () => void => {52+ this.#subscribers.add(listener);53+ return () => this.#subscribers.delete(listener);54+ };55+ mutate(update: Partial<StorageForkDiagnostics>): void {56+ this.#state = { ...this.#state, ...update };57+ for (const subscriber of this.#subscribers) subscriber();58+ }59+}60+61+const appId = "test-app";62+const markerKey = `lofi:local-data:${appId}`;63+const cookieName = `lofi-fork-${appId}`;64+65+function guardUnderTest(options: {66+ production?: boolean;67+ storage?: FakeStorage;68+ cookies?: FakeCookieJar;69+ standalone?: boolean;70+ syncing?: boolean;71+ diagnostics?: FakeDiagnostics;72+ probeLocalRows?: () => Promise<boolean>;73+ storageEvents?: EventTarget;74+ overrides?: Partial<StorageForkGuardDependencies>;75+} = {}) {76+ const storage = options.storage ?? new FakeStorage();77+ const cookies = options.cookies ?? new FakeCookieJar();78+ const diagnostics = options.diagnostics ?? new FakeDiagnostics();79+ const guard = createStorageForkGuard({80+ production: () => options.production ?? true,81+ appId: () => appId,82+ storage: () => storage,83+ storageKeys: () => storage.keys(),84+ readCookies: () => cookies.read(),85+ writeCookie: (cookie) => cookies.write(cookie),86+ cookiePath: () => "/",87+ secureContext: () => true,88+ environment: () => ({89+ displayModeStandalone: options.standalone ?? false,90+ navigatorStandalone: false,91+ }),92+ syncing: () => options.syncing ?? false,93+ diagnostics: () => diagnostics,94+ probeLocalRows: options.probeLocalRows ?? (() => Promise.resolve(false)),95+ storageEvents: () => options.storageEvents,96+ ...options.overrides,97+ });98+ return { guard, storage, cookies, diagnostics };99+}100+101+function waitForState(102+ guard: ReturnType<typeof createStorageForkGuard>,103+ predicate: (state: StorageForkState) => boolean,104+): Promise<StorageForkState> {105+ return new Promise((resolve) => {106+ const unsubscribe = guard.subscribe((state) => {107+ if (!predicate(state)) return;108+ unsubscribe();109+ resolve(state);110+ });111+ });112+}113+114+function storageEvent(key: string): Event {115+ return Object.assign(new Event("storage"), { key });116+}117+118+Deno.test("development leaves the guard unarmed and writes no cookie", () => {119+ const { guard, cookies } = guardUnderTest({ production: false });120+ guard.start();121+ const state = guard.getState();122+ assert(123+ state.state === "unarmed" && state.reason === "development",124+ "a development boot did not stay unarmed",125+ );126+ assert(cookies.lastWritten === null, "a development boot touched cookies");127+});128+129+Deno.test("no local data settles idle and deletes a stale flag cookie", () => {130+ const cookies = new FakeCookieJar();131+ cookies.values.set(cookieName, "1");132+ const { guard } = guardUnderTest({ cookies });133+ guard.start();134+ assert(guard.getState().state === "idle", "an empty browser context was not idle");135+ assert(!cookies.values.has(cookieName), "the stale flag cookie was not deleted");136+});137+138+Deno.test("a persisted marker without sync is at risk and refreshes the cookie", () => {139+ const storage = new FakeStorage();140+ storage.setItem(markerKey, "1");141+ const { guard, cookies } = guardUnderTest({ storage });142+ guard.start();143+ assert(guard.getState().state === "browser-data-at-risk", "marked local data was not at risk");144+ assert(cookies.values.get(cookieName) === "1", "the flag cookie was not written");145+ const written = cookies.lastWritten ?? "";146+ for (const attribute of ["Path=/", "SameSite=Lax", "Secure", "Max-Age=31536000"]) {147+ assert(written.includes(attribute), `the flag cookie lost its ${attribute} attribute`);148+ }149+});150+151+Deno.test("observed write activity persists the marker and flips to at risk", () => {152+ const { guard, storage, cookies, diagnostics } = guardUnderTest();153+ guard.start();154+ assert(guard.getState().state === "idle", "a fresh context did not start idle");155+ diagnostics.mutate({ localWaitCalls: 1 });156+ assert(guard.getState().state === "browser-data-at-risk", "write activity did not raise risk");157+ assert(storage.getItem(markerKey) === "1", "write activity did not persist the marker");158+ assert(cookies.values.get(cookieName) === "1", "write activity did not set the flag cookie");159+});160+161+Deno.test("electing sync settles idle and deletes the flag cookie", () => {162+ const storage = new FakeStorage();163+ storage.setItem(markerKey, "1");164+ let syncing = false;165+ const { guard, cookies, diagnostics } = guardUnderTest({166+ storage,167+ overrides: { syncing: () => syncing },168+ });169+ guard.start();170+ assert(guard.getState().state === "browser-data-at-risk", "unsynced data was not at risk");171+ syncing = true;172+ // Election recreates the runtime, which notifies the diagnostics feed.173+ diagnostics.mutate({});174+ assert(guard.getState().state === "idle", "electing sync did not settle idle");175+ assert(!cookies.values.has(cookieName), "electing sync did not delete the flag cookie");176+});177+178+Deno.test("a cross-tab storage event for this app re-evaluates the verdict", () => {179+ const storage = new FakeStorage();180+ storage.setItem(markerKey, "1");181+ const storageEvents = new EventTarget();182+ let syncing = false;183+ const { guard } = guardUnderTest({184+ storage,185+ storageEvents,186+ overrides: { syncing: () => syncing },187+ });188+ guard.start();189+ assert(guard.getState().state === "browser-data-at-risk", "unsynced data was not at risk");190+ syncing = true;191+ storageEvents.dispatchEvent(storageEvent(`lofi:sync-elected:${appId}`));192+ assert(guard.getState().state === "idle", "a cross-tab election was not observed");193+});194+195+Deno.test("a standalone launch into a fresh container with the flag detects the fork", () => {196+ const cookies = new FakeCookieJar();197+ cookies.values.set(cookieName, "1");198+ const { guard } = guardUnderTest({ cookies, standalone: true });199+ guard.start();200+ const state = guard.getState();201+ assert(state.state === "fork-detected", "the inherited flag cookie was not detected");202+ assert(203+ state.state === "fork-detected" && state.message.includes("still in Safari"),204+ "the fork notice does not say where the data lives",205+ );206+ assert(cookies.values.has(cookieName), "detection cleared the cookie before dismissal");207+});208+209+Deno.test("a standalone launch without the flag evaluates normally", () => {210+ const { guard } = guardUnderTest({ standalone: true });211+ guard.start();212+ assert(guard.getState().state === "idle", "a fresh standalone launch was not idle");213+});214+215+Deno.test("a used standalone container ignores a stale flag cookie", () => {216+ const storage = new FakeStorage();217+ storage.setItem(`lofi:schema-version:${appId}`, "{}");218+ const cookies = new FakeCookieJar();219+ cookies.values.set(cookieName, "1");220+ const { guard } = guardUnderTest({ storage, cookies, standalone: true });221+ guard.start();222+ assert(guard.getState().state !== "fork-detected", "a used container reported a fork");223+});224+225+Deno.test("a flag cookie for a different app never detects a fork", () => {226+ const cookies = new FakeCookieJar();227+ cookies.values.set("lofi-fork-other-app", "1");228+ const { guard } = guardUnderTest({ cookies, standalone: true });229+ guard.start();230+ assert(guard.getState().state === "idle", "another app's cookie leaked across");231+});232+233+Deno.test("dismissing a detected fork settles idle and deletes the cookie", () => {234+ const cookies = new FakeCookieJar();235+ cookies.values.set(cookieName, "1");236+ const { guard } = guardUnderTest({ cookies, standalone: true });237+ guard.start();238+ assert(guard.getState().state === "fork-detected", "the fork was not detected");239+ guard.dismissFork();240+ assert(guard.getState().state === "idle", "dismissal did not settle idle");241+ assert(!cookies.values.has(cookieName), "dismissal did not delete the flag cookie");242+ guard.dismissFork();243+ assert(guard.getState().state === "idle", "a repeated dismissal changed the verdict");244+});245+246+Deno.test("start is idempotent", () => {247+ const { guard, cookies } = guardUnderTest();248+ guard.start();249+ const first = cookies.lastWritten;250+ guard.start();251+ assert(guard.getState().state === "idle", "a second start changed the verdict");252+ assert(cookies.lastWritten === first, "a second start rewrote cookies");253+});254+255+Deno.test("throwing browser surfaces settle idle instead of crashing", () => {256+ const guard = createStorageForkGuard({257+ production: () => true,258+ appId: () => appId,259+ storage: () => {260+ throw new Error("blocked");261+ },262+ storageKeys: () => {263+ throw new Error("blocked");264+ },265+ readCookies: () => {266+ throw new Error("blocked");267+ },268+ writeCookie: () => {269+ throw new Error("blocked");270+ },271+ environment: () => ({ displayModeStandalone: false, navigatorStandalone: false }),272+ syncing: () => false,273+ diagnostics: () => new FakeDiagnostics(),274+ probeLocalRows: () => Promise.resolve(false),275+ storageEvents: () => undefined,276+ });277+ guard.start();278+ assert(guard.getState().state === "idle", "blocked storage did not settle idle");279+});280+281+Deno.test("the row probe runs once the persistent driver opens and marks found data", async () => {282+ const probes: true[] = [];283+ const probeCount = () => probes.length;284+ const { guard, storage, diagnostics } = guardUnderTest({285+ probeLocalRows: () => {286+ probes.push(true);287+ return Promise.resolve(true);288+ },289+ });290+ const atRisk = waitForState(guard, (state) => state.state === "browser-data-at-risk");291+ guard.start();292+ assert(probeCount() === 0, "the probe ran before the persistent driver opened");293+ diagnostics.mutate({ storageState: "persistent-driver-open" });294+ assert(probeCount() === 1, "the open driver did not trigger the probe");295+ await atRisk;296+ assert(storage.getItem(markerKey) === "1", "found rows did not persist the marker");297+ diagnostics.mutate({});298+ assert(probeCount() === 1, "the probe ran more than once");299+});300+301+Deno.test("describeStorageFork labels every state for diagnostic surfaces", () => {302+ const labels = [303+ describeStorageFork({ state: "unarmed", reason: "development" }),304+ describeStorageFork({ state: "unarmed", reason: "inactive" }),305+ describeStorageFork({ state: "idle" }),306+ describeStorageFork({ state: "browser-data-at-risk" }),307+ describeStorageFork({ state: "fork-detected", message: "" }),308+ ];309+ assert(new Set(labels).size === labels.length, "fork states share a label");310+ for (const label of labels) assert(label.length > 0, "a fork state lacks a label");311+});