clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

main
lofi / package / preact / DeviceStatus.tsx
DeviceStatus.tsx 11.8K
1import type { VNode } from "preact";2import { useEffect, useState } from "preact/hooks";3// Package-owned optional diagnostics UI.4import { useDeviceCapabilities } from "./use-device-capabilities.ts";5import { type CredentialOriginReport, getAuthCapability } from "../runtime/auth.ts";6import { settleUiMutation } from "../runtime/ui-mutation.ts";7import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";8import { PwaActions } from "./PwaActions.tsx";9import {10 getRuntimeDiagnostics,11 runtimeRecreatedEvent,12 subscribeRuntimeDiagnostics,13} from "../runtime/runtime.ts";14import { describeSchemaCompat } from "../runtime/schema-compat.ts";15import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork.ts";16import { useStorageFork } from "./use-storage-fork.ts";17import { readSession, type Session } from "../runtime/session.ts";18import { readSinkRestoreOutcome } from "../runtime/data-sink.ts";19import { describeStoreStatus, type RuntimeStoreStatus } from "../runtime/store-status.ts";20import { RuntimeRecovery } from "./RuntimeRecovery.tsx";2122// One label/value row inside a category's definition list.23function Row({ label, value }: { label: string; value: string }): VNode {24 return (25 <div>26 <dt>{label}</dt>27 <dd>{value}</dd>28 </div>29 );30}3132const available = (present: boolean) => (present ? "available" : "missing");3334/** One-line credential-origin verdict that distinguishes API support from deployability. */35export function describeCredentialOrigin(origin: CredentialOriginReport): string {36 switch (origin.status) {37 case "stable":38 return `stable — ${origin.rpId}`;39 case "local-only":40 return `local development only — ${origin.rpId}`;41 case "unverified":42 return `unverified — ${origin.rpId}`;43 case "blocked":44 return origin.rpId ? `blocked — ${origin.rpId}` : "blocked";45 }46}4748/**49 * The one-line Data sync verdict. The blocked dispositions are first-class —50 * the report must say *why* nothing is syncing, not merely that it is not:51 * the election belongs to another account, the store refused (no schema, or52 * the ticket is no longer accepted), the declared sink's sealed record cannot53 * be opened on this device, or no sync location exists at all. Exported for54 * tests; the entry does not re-export it.55 */56export function describeSyncState(input: {57 syncing: boolean;58 syncAvailable: boolean;59 ownerMismatch: boolean;60 storeState: RuntimeStoreStatus["state"];61 sinkUnopenable: boolean;62}): string {63 if (input.ownerMismatch) return "paused — sync belongs to another account on this device";64 if (input.storeState === "no_schema") return "blocked — store has no schema for this app";65 if (input.storeState === "ticket_rejected") return "blocked — ticket no longer accepted";66 if (input.sinkUnopenable) return "blocked — sync location record unopenable";67 if (input.syncing) return "syncing to your account";68 if (input.syncAvailable) return "available — not yet backed up";69 return "local-only";70}7172/**73 * Renders the Device gate: every subsystem's live status — storage, sync,74 * auth, and PWA — grouped by the module that owns it, so the panel doubles75 * as a map of where to hook in. Optional: it is built entirely on public76 * runtime APIs (device capabilities, PWA state, runtime diagnostics, the77 * session), so an application can replace it with its own status UI over the78 * same sources.79 *80 * @example81 * ```tsx82 * import { DeviceStatus } from "@nzip/lofi/preact";83 *84 * export function SettingsPage() {85 * return <DeviceStatus />;86 * }87 * ```88 *89 * @returns The device diagnostics panel, grouped by owning subsystem.90 */91export function DeviceStatus(): VNode {92 const { report, requestPersistence } = useDeviceCapabilities();93 const fork = useStorageFork();94 const [pwa, setPwa] = useState<PwaState>(getPwaState());95 const [session, setSession] = useState<Session | null>(null);96 const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());97 const [credentialOrigin, setCredentialOrigin] = useState<CredentialOriginReport | null>(null);9899 useEffect(() => subscribePwaState(setPwa), []);100 useEffect(101 () =>102 subscribeRuntimeDiagnostics(() => {103 setRuntimeDiagnostics(getRuntimeDiagnostics());104 // Initial boot restores a sealed runtime-declared sink asynchronously.105 // An island can mount before that restore completes, so refresh the106 // synchronous session snapshot whenever boot diagnostics advance.107 setSession(readSession());108 }),109 [],110 );111 useEffect(() => {112 const refresh = () => setSession(readSession());113 refresh();114 // Re-read after electing to sync or restoring an account recreates the runtime.115 globalThis.addEventListener(runtimeRecreatedEvent, refresh);116 return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);117 }, []);118 useEffect(() => {119 let active = true;120 void getAuthCapability().then((capability) => {121 if (active) setCredentialOrigin(capability.origin);122 });123 return () => {124 active = false;125 };126 }, []);127128 if (!report) return <p class="device-status">Checking device capabilities…</p>;129130 const synced = Boolean(session?.syncAvailable);131 const ownerMismatch = Boolean(session?.syncOwnerMismatch);132 const syncOwner = runtimeDiagnostics.syncOwner;133 const sinkUnopenable = !session?.sink && readSinkRestoreOutcome() === "unopenable";134 const sinkState = session?.sink135 ? session.sink.source === "declared"136 ? `declared — ${session.sink.label ?? session.sink.host}`137 : "configured (build default)"138 : sinkUnopenable139 ? "declared — record unopenable on this device"140 : "not configured";141 const syncState = describeSyncState({142 syncing: Boolean(session?.syncing),143 syncAvailable: synced,144 ownerMismatch,145 storeState: runtimeDiagnostics.storeStatus.state,146 sinkUnopenable,147 });148 const backupState = session?.backedUp149 ? "recovery phrase"150 : session?.syncAvailable151 ? "not backed up"152 : "local-only";153154 return (155 <section class="device-status" aria-labelledby="device-status-title">156 <header>157 <p class="eyebrow">Device gate</p>158 <h2 id="device-status-title">Deployment status &amp; integration points</h2>159 </header>160161 <div class="device-category">162 <h3>Shared worker &amp; durable storage</h3>163 <p class="device-hook">164 Hook in: <code>@nzip/lofi</code> device capabilities and runtime165 </p>166 <dl>167 <Row168 label="Durable driver"169 value={report.durableDriverSupported ? "supported" : "blocked"}170 />171 <Row label="Secure context" value={report.secureContext ? "secure" : "not secure"} />172 <Row label="OPFS" value={available(report.opfs)} />173 <Row label="SharedWorker" value={available(report.sharedWorker)} />174 <Row label="Web Locks" value={available(report.webLocks)} />175 <Row label="MessageChannel" value={available(report.messageChannel)} />176 <Row label="Storage persistence" value={report.persistentPermission} />177 <Row178 label="Runtime startup"179 value={runtimeDiagnostics.startupFailure?.code ??180 (runtimeDiagnostics.storageState === "persistent-driver-open" ? "ready" : "opening")}181 />182 </dl>183 <RuntimeRecovery failure={runtimeDiagnostics.startupFailure} />184 <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>185 Request storage persistence186 </button>187 <p>188 OPFS capability and eviction protection are separate. A declined persistence request does189 not mean the Jazz driver is using memory.190 </p>191 </div>192193 <div class="device-category">194 <h3>Data sync</h3>195 <p class="device-hook">196 Hook in: <code>@nzip/lofi</code> session and runtime197 </p>198 <dl>199 <Row label="Sync" value={syncState} />200 <Row label="Sync location" value={sinkState} />201 <Row label="Store" value={describeStoreStatus(runtimeDiagnostics.storeStatus)} />202 </dl>203 {ownerMismatch && (204 <p>205 Sync on this device was set up by{" "}206 {syncOwner.state === "mismatch" && syncOwner.owner_user_id207 ? (208 <>209 the account <code>{syncOwner.owner_user_id}</code>210 </>211 )212 : "a different account"}. Nothing connects while the accounts differ, so neither store213 can merge into the other. Stop syncing in the account panel to release this device for214 the current account, or restore the owning account to resume.215 </p>216 )}217 {runtimeDiagnostics.storeStatus.state === "no_schema" && (218 <p>{runtimeDiagnostics.storeStatus.message}</p>219 )}220 {runtimeDiagnostics.storeStatus.state === "ticket_rejected" && (221 <p>222 The node no longer accepts this device's credential — the ticket was revoked, the node223 was reset, or this device's key is gone. Clear the sync location and enroll a fresh224 ticket; local data is intact and pushes up on re-enrollment.225 </p>226 )}227 {sinkUnopenable && (228 <p>229 A sync location is declared on this device, but its sealed record cannot be opened — the230 device key that sealed it is gone, usually a cleared browser store. The device runs231 local-only until re-enrollment. Clear the sync location and enroll a fresh ticket; local232 data is intact and pushes up.233 </p>234 )}235 {!synced && !ownerMismatch && (236 <p>237 Enroll an app-connect ticket from your node to replicate writes to your account — a238 saved ticket autofills from your password manager. Building with{" "}239 <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}240 remains the compiled-in alternative.241 </p>242 )}243 </div>244245 <div class="device-category">246 <h3>Auth</h3>247 <p class="device-hook">248 Hook in: <code>@nzip/lofi</code> auth and session249 </p>250 <dl>251 <Row label="Identity" value="local-first account" />252 <Row label="Backup" value={backupState} />253 <Row label="WebAuthn API" value={available(report.webAuthn)} />254 <Row label="PRF API (at-rest key)" value={report.prf} />255 <Row256 label="Credential origin"257 value={credentialOrigin ? describeCredentialOrigin(credentialOrigin) : "checking"}258 />259 </dl>260 {credentialOrigin && credentialOrigin.status !== "stable" && (261 <p>{credentialOrigin.action}</p>262 )}263 </div>264265 <div class="device-category">266 <h3>PWA</h3>267 <p class="device-hook">268 Hook in: <code>@nzip/lofi</code> PWA state269 </p>270 <dl>271 <Row label="Display mode" value={report.displayMode} />272 <Row label="Install" value={pwa.install} />273 <Row label="Service worker" value={pwa.worker} />274 <Row label="Update" value={pwa.update} />275 <Row276 label="Schema compatibility"277 value={describeSchemaCompat(runtimeDiagnostics.schemaCompat)}278 />279 <Row label="Storage container" value={describeStorageFork(fork)} />280 </dl>281 {(runtimeDiagnostics.schemaCompat.state === "data-ahead" ||282 runtimeDiagnostics.schemaCompat.state === "updating") && (283 <p>{runtimeDiagnostics.schemaCompat.message}</p>284 )}285 {fork.state === "fork-detected" && (286 <>287 <p>{fork.message}</p>288 <button type="button" onClick={() => dismissStorageFork()}>289 Dismiss290 </button>291 </>292 )}293 <PwaActions title="Install & updates" />294 </div>295 </section>296 );297}