feat: standalone download-consent gate + truthful loading states (standalone-load-ux 0001/0002)
Cold loads on versioned CDN deploys now show a consent card (editor MB + symbol/footprint lib figures, downloaded-now vs on-demand) and wait for OK before any big fetch; warm loads skip it and show truthful stages (loading from cache / Compiling / Starting KiCad) instead of the first-download line. - wasm-assets: resolveWasmMeta (bundle/ver/sizes), download-completion marker keyed by content-addressed bundle/ver, update wording, auto-download opt-out, HEAD size fallback - boot: manifest raw size as the progress total (fixes the br/gzip Content-Length mismatch), marker written after download+instantiate succeed - cdn-source: syncState() — IDB warmth peek + sizes.json cold sums - synced-source: syncState() from the backend envelope's sync refs (private platform); remote-source passes libSchema.sync through - publish-wasm: manifest schema 2 with per-bundle sizes (registry-persisted, reuse + snapshot modes); publish-libs: sizes.json sibling key + top-up mode - fixed 4 stale unit tests (bundle mapping, session-identity email) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011CC8aAnUUHcnHy3QCJtUwb
This commit is contained in:
parent
cf2fd424b5
commit
8ee8db69e6
14 changed files with 972 additions and 73 deletions
|
|
@ -21,9 +21,15 @@
|
|||
// — the publish-time footprint index: unique electrical pad count per footprint,
|
||||
// so the editor's symbol-chooser footprint selector can filter EVERY footprint
|
||||
// lib without fat-loading a single body (kicad pcbnew.cpp `filterFootprints`).
|
||||
// + `<prefix>/<libTag>/sizes.json` { schema, tag, libs: { <libId>: <bundleBytes> } }
|
||||
// — per-lib bundle byte counts for the standalone's download-consent dialog
|
||||
// (standalone-load-ux 0001). A SEPARATE key (not a manifest.json field) on
|
||||
// purpose: manifest.json is stored IMMUTABLE, so re-putting it to add sizes
|
||||
// could serve stale from edge caches; a new key can't.
|
||||
// All immutable (content is pinned by the tag).
|
||||
// A tag published before fp-index.json existed gets an INDEX-ONLY top-up run:
|
||||
// bundles/manifests are skipped (immutable + present), only the index is put.
|
||||
// A tag published before fp-index.json / sizes.json existed gets a TOP-UP run:
|
||||
// bundles/manifests are skipped (immutable + present), only the missing index
|
||||
// and/or sizes files are computed (pure local work) and put.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
|
|
@ -107,19 +113,23 @@ async function main(): Promise<void> {
|
|||
const enc = new TextEncoder();
|
||||
const topKey = `${a.prefix}/${a.libTag}/manifest.json`;
|
||||
const indexKey = `${a.prefix}/${a.libTag}/fp-index.json`;
|
||||
const sizesKey = `${a.prefix}/${a.libTag}/sizes.json`;
|
||||
|
||||
// Skip-if-exists: the snapshot is immutable + content-pinned by the tag.
|
||||
// A tag published before fp-index.json existed drops into INDEX-ONLY mode:
|
||||
// recompute + put just the footprint index (pure local work; no bundle puts).
|
||||
const indexOnly = !a.force && !!store.getJSON(topKey);
|
||||
if (indexOnly && store.getJSON(indexKey)) {
|
||||
// A tag published before fp-index.json / sizes.json existed drops into a
|
||||
// TOP-UP run: recompute + put just the missing derived files (pure local
|
||||
// work; no bundle/manifest puts).
|
||||
const published = !a.force && !!store.getJSON(topKey);
|
||||
const haveIndex = published && !!store.getJSON(indexKey);
|
||||
const haveSizes = published && !!store.getJSON(sizesKey);
|
||||
if (published && haveIndex && haveSizes) {
|
||||
console.log(`publish-libs: ${topKey} already published — skipping (use --force)`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`publish-libs: tag=${a.libTag} driver=${store.kind} → ${a.prefix}/${a.libTag}/` +
|
||||
(indexOnly ? " (index-only top-up)" : ""),
|
||||
(published ? " (top-up: derived files only)" : ""),
|
||||
);
|
||||
|
||||
// Source the full set from upstream when asked (CI path); --symbols-src /
|
||||
|
|
@ -134,19 +144,24 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
const libs = await extractAllLibs({
|
||||
// Index-only: the index covers footprints only, so skip symbol extraction.
|
||||
symbolsSrc: indexOnly ? undefined : (a.symbolsSrc ?? undefined),
|
||||
// Symbols are only needed for bundles (fresh publish) or sizes (top-up);
|
||||
// the fp-index alone covers footprints only.
|
||||
symbolsSrc: published && haveSizes ? undefined : (a.symbolsSrc ?? undefined),
|
||||
footprintsSrc: a.footprintsSrc ?? undefined,
|
||||
});
|
||||
|
||||
const topLibs: Array<{ id: string; name: string; kind: string; itemCount: number }> = [];
|
||||
const fpIndexLibs: Record<string, Array<[string, number]>> = {};
|
||||
const sizesLibs: Record<string, number> = {};
|
||||
let totalItems = 0;
|
||||
for (const { lib, kind, items } of libs) {
|
||||
if (kind === "footprint") {
|
||||
if (kind === "footprint" && !haveIndex) {
|
||||
fpIndexLibs[lib] = items.map((it) => [it.name, countUniquePads(it.body)]);
|
||||
}
|
||||
if (indexOnly) continue; // bundles/manifests already live under this tag
|
||||
// bundles/manifests already live under a published tag; a sizes top-up
|
||||
// still re-encodes each bundle LOCALLY (deterministic from the same tag's
|
||||
// sources) to measure it — nothing is re-put.
|
||||
if (published && haveSizes) continue;
|
||||
|
||||
const bodies = items.map(
|
||||
(it): [string, Uint8Array] => [`${it.kind}/${it.name}`, enc.encode(it.body)],
|
||||
|
|
@ -156,9 +171,13 @@ async function main(): Promise<void> {
|
|||
entries[path] = { hash: sha256hex(body), size: body.length, mtime: 0 };
|
||||
}
|
||||
const manifest: SyncManifest = { version: 1, entries };
|
||||
const bundle = encodeBundle(manifest, bodies);
|
||||
sizesLibs[lib] = bundle.byteLength;
|
||||
if (published) continue;
|
||||
|
||||
const base = `${a.prefix}/${a.libTag}/${lib}`;
|
||||
putJSON(store, `${base}/manifest`, manifest, IMMUTABLE);
|
||||
store.put(`${base}/bundle`, encodeBundle(manifest, bodies), {
|
||||
store.put(`${base}/bundle`, bundle, {
|
||||
contentType: "application/octet-stream",
|
||||
contentEncoding: null,
|
||||
cacheControl: IMMUTABLE,
|
||||
|
|
@ -167,18 +186,25 @@ async function main(): Promise<void> {
|
|||
totalItems += items.length;
|
||||
}
|
||||
|
||||
if (!indexOnly) {
|
||||
if (!published) {
|
||||
topLibs.sort((x, y) => x.id.localeCompare(y.id));
|
||||
putJSON(store, topKey, { schema: 1, tag: a.libTag, libs: topLibs }, IMMUTABLE);
|
||||
}
|
||||
putJSON(store, indexKey, { schema: 1, tag: a.libTag, libs: fpIndexLibs }, IMMUTABLE);
|
||||
if (!haveIndex) {
|
||||
putJSON(store, indexKey, { schema: 1, tag: a.libTag, libs: fpIndexLibs }, IMMUTABLE);
|
||||
}
|
||||
if (!haveSizes) {
|
||||
putJSON(store, sizesKey, { schema: 1, tag: a.libTag, libs: sizesLibs }, IMMUTABLE);
|
||||
}
|
||||
|
||||
const fpIndexCount = Object.values(fpIndexLibs).reduce((n, v) => n + v.length, 0);
|
||||
console.log(
|
||||
indexOnly
|
||||
? `publish-libs: done — fp-index only (${fpIndexCount} footprints) → ${indexKey}`
|
||||
published
|
||||
? `publish-libs: done — top-up (${haveIndex ? "" : `fp-index: ${fpIndexCount} footprints`}` +
|
||||
`${!haveIndex && !haveSizes ? ", " : ""}` +
|
||||
`${haveSizes ? "" : `sizes: ${Object.keys(sizesLibs).length} libs`})`
|
||||
: `publish-libs: done — ${topLibs.length} libs, ${totalItems} items ` +
|
||||
`(+fp-index: ${fpIndexCount} footprints) → ${topKey}`,
|
||||
`(+fp-index: ${fpIndexCount} footprints, +sizes) → ${topKey}`,
|
||||
);
|
||||
if (store.kind === "local") console.log(`local layout under: ${a.out}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,13 @@ async function main() {
|
|||
registry.index ||= {};
|
||||
registry.tools ||= {};
|
||||
|
||||
const manifest = { schema: 1, tag: a.tag, builtAt, tools: {} };
|
||||
// schema 2: adds `sizes` — per-tool byte counts the standalone's download
|
||||
// consent dialog + progress bar read (standalone-load-ux 0001). `wasm` is the
|
||||
// RAW (decoded) size (matches the byte-counting progress stream), `wasmStored`
|
||||
// / `totalStored` are over-the-wire (post-br/gzip) sizes. Additive: old
|
||||
// clients read only `tools`; new clients tolerate a missing/partial `sizes`
|
||||
// (tools published before this schema carry none in the registry).
|
||||
const manifest = { schema: 2, tag: a.tag, builtAt, tools: {}, sizes: {} };
|
||||
|
||||
// Snapshot mode (the tag deploy): pin manifest-<tag> to the CURRENT published
|
||||
// per-tool versions and STOP — no build, no upload. Honors "reuse prebuilt":
|
||||
|
|
@ -193,6 +199,7 @@ async function main() {
|
|||
`mode) before snapshotting a release manifest`,
|
||||
);
|
||||
manifest.tools[tool] = entry.version;
|
||||
if (entry.sizes) manifest.sizes[tool] = entry.sizes;
|
||||
}
|
||||
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
|
||||
console.log(
|
||||
|
|
@ -207,16 +214,27 @@ async function main() {
|
|||
|
||||
let reused = 0;
|
||||
|
||||
// Per-tool sizes destined for manifest.sizes + the registry entry. Reused
|
||||
// tools inherit the registry's stored sizes when they match this version
|
||||
// (compression is skipped on reuse, so stored sizes can't be recomputed);
|
||||
// pre-schema-2 registry entries yield a raw-only partial the client treats
|
||||
// as "stored size unknown".
|
||||
const sizesByTool = {};
|
||||
|
||||
// Plan: hash every tool, decide reuse vs upload (moved-tag guard included).
|
||||
const plan = [];
|
||||
for (const tool of a.tools) {
|
||||
const files = gather(tool, a.src);
|
||||
const hash = toolContentHash(files);
|
||||
const idx = (registry.index[tool] ||= {});
|
||||
const rawWasm = files.find((f) => f.name === `${tool}.wasm`).bytes.length;
|
||||
|
||||
let ver = idx[hash];
|
||||
if (ver) {
|
||||
reused++;
|
||||
const prev = registry.tools[tool];
|
||||
sizesByTool[tool] =
|
||||
prev?.version === ver && prev.sizes ? prev.sizes : { wasm: rawWasm };
|
||||
console.log(` ${tool}: reuse ${ver} (${hash.slice(0, 19)}…)`);
|
||||
} else {
|
||||
ver = a.tag;
|
||||
|
|
@ -244,6 +262,12 @@ async function main() {
|
|||
|
||||
for (const { tool, files, hash, ver } of plan) {
|
||||
const sizes = files.map((f) => putFile(store, `${P}/${tool}/${ver}/${f.name}`, f));
|
||||
const wasmFile = sizes.find((s) => s.name === `${tool}.wasm`);
|
||||
sizesByTool[tool] = {
|
||||
wasm: wasmFile.raw,
|
||||
wasmStored: wasmFile.stored,
|
||||
totalStored: sizes.reduce((s, x) => s + x.stored, 0),
|
||||
};
|
||||
putJSON(
|
||||
store,
|
||||
`${P}/${tool}/${ver}/meta.json`,
|
||||
|
|
@ -264,6 +288,13 @@ async function main() {
|
|||
}
|
||||
const uploaded = plan.length;
|
||||
|
||||
for (const tool of a.tools) {
|
||||
const s = sizesByTool[tool];
|
||||
if (!s) continue;
|
||||
manifest.sizes[tool] = s;
|
||||
registry.tools[tool].sizes = s;
|
||||
}
|
||||
|
||||
// Browser-facing manifest + convenience pointer, both uncached.
|
||||
putJSON(store, `${P}/manifest-${a.tag}.json`, manifest, NO_STORE);
|
||||
putJSON(store, `${P}/manifest-latest.json`, { tag: a.tag }, NO_STORE);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 6a25a549a6d4315217f6ef359ef5346fdeeedf88
|
||||
Subproject commit 6189307bf50a3fc1d477f0616434e8c9e2f980b9
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
APP_URL,
|
||||
|
|
@ -33,7 +33,16 @@ import { redirectTargetFor } from "@/lib/redirect";
|
|||
import { loadSessionIdentity } from "@/lib/session-identity";
|
||||
import { setTheme, useThemeValue } from "@/lib/theme";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { resolveWasmBase } from "@/wasm/wasm-assets";
|
||||
import {
|
||||
autoDownloadEnabled,
|
||||
fetchWasmStoredSize,
|
||||
hasAnyWasmDownload,
|
||||
isWasmDownloaded,
|
||||
markWasmDownloaded,
|
||||
resolveWasmMeta,
|
||||
setAutoDownloadEnabled,
|
||||
type WasmMeta,
|
||||
} from "@/wasm/wasm-assets";
|
||||
import {
|
||||
LIB_BUSY_EVENT,
|
||||
LIB_ERROR_EVENT,
|
||||
|
|
@ -44,12 +53,13 @@ import {
|
|||
type LibItemUpdatedDetail,
|
||||
type LibLoadingDetail,
|
||||
type LibsSource,
|
||||
type LibsSyncState,
|
||||
} from "@/wasm/libs/source";
|
||||
import {
|
||||
MODELS_LOADING_EVENT,
|
||||
type ModelsLoadingDetail,
|
||||
} from "@/wasm/libs/models-bridge";
|
||||
import { memfsFilePath, memfsProjectDir, TOOL_FRAME } from "@/wasm/constants";
|
||||
import { memfsFilePath, memfsProjectDir, TOOL_BUNDLE, TOOL_FRAME } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
|
||||
import type {
|
||||
|
|
@ -143,6 +153,64 @@ const LIB_KIND_FOR_TOOL: Partial<Record<Tool, "symbol" | "footprint">> = {
|
|||
footprint_editor: "footprint",
|
||||
pcbnew: "footprint",
|
||||
};
|
||||
|
||||
/** What the download-consent dialog quotes (standalone-load-ux 0001). */
|
||||
interface ConsentInfo {
|
||||
/** Over-the-wire bytes for the editor bundle — null when the CDN carries no
|
||||
* size info and HEAD yielded none ("large download" wording then). */
|
||||
toolBytes: number | null;
|
||||
/** A previous version of this bundle was downloaded → word it as an update. */
|
||||
update: boolean;
|
||||
/** The lib kind pre-synced at boot for this tool ("downloaded now"). */
|
||||
libNowKind: "symbol" | "footprint" | null;
|
||||
libNow: LibsSyncState | null;
|
||||
/** The other kind a merged-bundle session can pull lazily ("only if used"). */
|
||||
libLaterKind: "symbol" | "footprint" | null;
|
||||
libLater: LibsSyncState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather the consent dialog's figures. Everything is best-effort: only small
|
||||
* JSON/HEAD requests run here (never a bundle or the wasm), and any missing
|
||||
* piece degrades to vaguer wording rather than blocking the dialog.
|
||||
*/
|
||||
async function gatherConsentInfo(
|
||||
meta: WasmMeta,
|
||||
source: LibsSource | null,
|
||||
tool: Tool,
|
||||
): Promise<ConsentInfo> {
|
||||
let toolBytes = meta.sizes?.totalStored ?? null;
|
||||
if (toolBytes === null) {
|
||||
toolBytes = await fetchWasmStoredSize(meta.base, meta.bundle);
|
||||
}
|
||||
const libNowKind = LIB_KIND_FOR_TOOL[tool] ?? null;
|
||||
// The merged kicad_editor bundle seeds BOTH lib tables — the other kind loads
|
||||
// lazily per-lib when a cross-face feature reaches it (see boot.ts libKinds).
|
||||
const libLaterKind =
|
||||
TOOL_BUNDLE[tool] === "kicad_editor" && libNowKind
|
||||
? libNowKind === "symbol"
|
||||
? ("footprint" as const)
|
||||
: ("symbol" as const)
|
||||
: null;
|
||||
const state = async (
|
||||
kind: "symbol" | "footprint" | null,
|
||||
): Promise<LibsSyncState | null> => {
|
||||
if (!kind || !source?.syncState) return null;
|
||||
try {
|
||||
return await source.syncState(kind);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
toolBytes,
|
||||
update: hasAnyWasmDownload(meta.bundle),
|
||||
libNowKind,
|
||||
libNow: await state(libNowKind),
|
||||
libLaterKind,
|
||||
libLater: await state(libLaterKind),
|
||||
};
|
||||
}
|
||||
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
|
||||
".sch": "eeschema",
|
||||
".brd": "pcbnew",
|
||||
|
|
@ -858,6 +926,13 @@ export function WasmTool({
|
|||
total: number;
|
||||
} | null>(null);
|
||||
const [slow, setSlow] = React.useState(false);
|
||||
// Download-consent gate (standalone-load-ux 0001): non-null while the boot
|
||||
// waits on the user's OK before pulling the (large) cold wasm + lib bundles.
|
||||
const [consent, setConsent] = React.useState<ConsentInfo | null>(null);
|
||||
const consentResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
|
||||
// This bundle+version finished downloading before (completion marker) — the
|
||||
// load overlay says "from cache" instead of the first-download excuse.
|
||||
const [warmBoot, setWarmBoot] = React.useState(false);
|
||||
// A library item currently being fetched (open/save), for a transient spinner.
|
||||
const [libBusy, setLibBusy] = React.useState<string | null>(null);
|
||||
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in
|
||||
|
|
@ -1041,12 +1116,13 @@ export function WasmTool({
|
|||
|
||||
// "Taking too long": once the tool has been loading for a while without
|
||||
// becoming ready, surface a hint (slow link / something may be wrong) + a
|
||||
// reload, so a stalled boot doesn't look like a frozen blank screen.
|
||||
// reload, so a stalled boot doesn't look like a frozen blank screen. Paused
|
||||
// while the consent dialog is up — waiting on the user isn't "slow".
|
||||
React.useEffect(() => {
|
||||
if (ready) return;
|
||||
if (ready || consent) return;
|
||||
const t = setTimeout(() => setSlow(true), 60_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [ready]);
|
||||
}, [ready, consent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const win = window as ToolWindow;
|
||||
|
|
@ -1242,12 +1318,32 @@ export function WasmTool({
|
|||
const identityReady = loadSessionIdentity(API_BASE_URL);
|
||||
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
|
||||
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
|
||||
const base = await resolveWasmBase(tool, assetBaseUrl);
|
||||
const meta = await resolveWasmMeta(tool, assetBaseUrl);
|
||||
const base = meta.base;
|
||||
// One source instance, shared by the wasm provider AND the pre-sync below
|
||||
// (libsSourceConfig builds a fresh one each call — their SyncStack caches
|
||||
// must be the same object for the warm-up to benefit the editor).
|
||||
const source =
|
||||
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
|
||||
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
||||
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
||||
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
|
||||
// e2e assetBaseUrl overrides have no version and skip it), when this
|
||||
// exact bundle+version hasn't finished downloading before, and the user
|
||||
// hasn't opted into silent downloads. Only small JSON/HEAD requests
|
||||
// happen before consent.
|
||||
const warm = isWasmDownloaded(meta.bundle, meta.ver);
|
||||
setWarmBoot(warm);
|
||||
if (meta.ver && !warm && !autoDownloadEnabled()) {
|
||||
const info = await gatherConsentInfo(meta, source, tool);
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
consentResolveRef.current = resolve;
|
||||
setConsent(info);
|
||||
});
|
||||
consentResolveRef.current = null;
|
||||
setConsent(null);
|
||||
if (!ok) return; // unmounted while waiting — never start the download
|
||||
}
|
||||
// Pre-warm the lib bundles into IDB in PARALLEL with the wasm download, so
|
||||
// the editor's first enumerate reads a warm cache instead of freezing on N
|
||||
// cold bundle fetches. Non-blocking + best-effort; the SyncStack dedups, so
|
||||
|
|
@ -1276,6 +1372,13 @@ export function WasmTool({
|
|||
onStatus: setStatus,
|
||||
onAbort: oom.onAbort,
|
||||
onProgress: (loaded, total) => setProgress({ loaded, total }),
|
||||
// Truthful loading states (standalone-load-ux 0001): the manifest's
|
||||
// raw wasm size fixes the progress total under br/gzip; the marker
|
||||
// flips the label to "Loading" (HTTP cache) vs "Downloading"; the
|
||||
// completion marker is recorded once download + compile succeeded.
|
||||
expectedWasmBytes: meta.sizes?.wasm ?? null,
|
||||
warmStart: warm,
|
||||
onWasmInstantiated: () => markWasmDownloaded(meta.bundle, meta.ver),
|
||||
libsSource: source,
|
||||
// 3D models: lazy per-board source (null unless the CDN manifest is
|
||||
// configured) — feeds the board prescan + the viewer's ensure fallback.
|
||||
|
|
@ -1490,6 +1593,10 @@ export function WasmTool({
|
|||
})();
|
||||
|
||||
return () => {
|
||||
// A consent dialog pending at unmount resolves false — the boot IIFE
|
||||
// bails without ever starting the download.
|
||||
consentResolveRef.current?.(false);
|
||||
consentResolveRef.current = null;
|
||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||
win.removeEventListener("keydown", chromeHotkey, true);
|
||||
commentsRef.current?.destroy();
|
||||
|
|
@ -1596,6 +1703,14 @@ export function WasmTool({
|
|||
Reload
|
||||
</button>
|
||||
</>
|
||||
) : consent ? (
|
||||
<DownloadConsent
|
||||
info={consent}
|
||||
onAccept={(always) => {
|
||||
if (always) setAutoDownloadEnabled(true);
|
||||
consentResolveRef.current?.(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
|
|
@ -1609,7 +1724,9 @@ export function WasmTool({
|
|||
</p>
|
||||
)}
|
||||
<p className="font-mono text-xs text-white/40">
|
||||
First load downloads the tool (large) — this can take a moment.
|
||||
{warmBoot
|
||||
? "Loading from your browser's cache — no download needed."
|
||||
: "Downloading the editor — it's cached for future visits."}
|
||||
</p>
|
||||
{slow && (
|
||||
<>
|
||||
|
|
@ -1902,6 +2019,115 @@ export function WasmTool({
|
|||
);
|
||||
}
|
||||
|
||||
/** "~173 MB" — coarse on purpose; these are quotes, not meters. */
|
||||
function approxMB(bytes: number): string {
|
||||
const mb = bytes / 1e6;
|
||||
return `~${mb >= 10 ? Math.round(mb) : Math.max(0.1, mb).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** One consent row's size figure, from a (possibly partial) LibsSyncState. */
|
||||
function libStateLabel(s: LibsSyncState | null): string | null {
|
||||
if (!s) return null;
|
||||
if (s.total > 0 && s.warm >= s.total) return "already cached";
|
||||
const cold = s.total - s.warm;
|
||||
const count = `${cold} librar${cold === 1 ? "y" : "ies"}`;
|
||||
if (!s.sizesKnown) return count;
|
||||
return s.warm > 0 ? `${approxMB(s.coldBytes)} (${count})` : approxMB(s.coldBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The download-consent card (standalone-load-ux 0001): what's about to be
|
||||
* pulled onto this device — the editor bundle now, the tool's lib kind now,
|
||||
* the other kind later on demand — and an OK that actually gates the fetches.
|
||||
*/
|
||||
function DownloadConsent({
|
||||
info,
|
||||
onAccept,
|
||||
}: {
|
||||
info: ConsentInfo;
|
||||
onAccept: (always: boolean) => void;
|
||||
}) {
|
||||
const [always, setAlways] = React.useState(false);
|
||||
const kindTitle = (k: "symbol" | "footprint") =>
|
||||
k === "symbol" ? "Symbol libraries" : "Footprint libraries";
|
||||
const row = (
|
||||
title: string,
|
||||
detail: string,
|
||||
figure: string | null,
|
||||
testid: string,
|
||||
) => (
|
||||
<li
|
||||
data-testid={testid}
|
||||
className="flex items-baseline gap-3 border-t border-white/10 py-2 first:border-t-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white/90">{title}</p>
|
||||
<p className="text-xs text-white/50">{detail}</p>
|
||||
</div>
|
||||
{figure && (
|
||||
<span className="whitespace-nowrap font-mono text-xs text-white/70">
|
||||
{figure}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="download-consent"
|
||||
className="flex w-full max-w-md flex-col items-center gap-4 px-6"
|
||||
>
|
||||
<Download size={32} className="text-white/70" />
|
||||
<h2 className="text-base font-semibold">
|
||||
{info.update ? "Editor update available" : "One-time download needed"}
|
||||
</h2>
|
||||
<p className="text-center text-sm text-white/70">
|
||||
PCBJam runs KiCad fully in your browser.{" "}
|
||||
{info.update
|
||||
? "This release ships a new editor build, so it needs downloading again — it's cached after that."
|
||||
: "Opening this tool downloads it once — repeat visits load from your browser's cache."}
|
||||
</p>
|
||||
<ul className="w-full rounded-lg bg-white/5 px-4 py-1">
|
||||
{row(
|
||||
"Editor engine",
|
||||
"the KiCad build, downloaded now",
|
||||
info.toolBytes !== null ? approxMB(info.toolBytes) : "large (hundreds of MB)",
|
||||
"consent-row-tool",
|
||||
)}
|
||||
{info.libNowKind &&
|
||||
row(
|
||||
kindTitle(info.libNowKind),
|
||||
"downloaded now — this editor browses them",
|
||||
libStateLabel(info.libNow),
|
||||
"consent-row-now",
|
||||
)}
|
||||
{info.libLaterKind &&
|
||||
row(
|
||||
kindTitle(info.libLaterKind),
|
||||
"downloaded later, only if you use them",
|
||||
libStateLabel(info.libLater),
|
||||
"consent-row-later",
|
||||
)}
|
||||
</ul>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={always}
|
||||
onChange={(e) => setAlways(e.target.checked)}
|
||||
className="accent-white/80"
|
||||
/>
|
||||
Always download without asking
|
||||
</label>
|
||||
<button
|
||||
data-testid="consent-accept"
|
||||
className="rounded bg-white/90 px-4 py-1.5 text-sm font-medium text-[#1a1a2e] hover:bg-white"
|
||||
onClick={() => onAccept(always)}
|
||||
>
|
||||
Download & open
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-width lib pre-sync line, e.g. "Syncing symbol libraries — 42/208".
|
||||
* The prefix is constant and `done` is space-padded to `total`'s digit count,
|
||||
|
|
|
|||
|
|
@ -23,8 +23,13 @@ describe("loadSessionIdentity", () => {
|
|||
expect(await loadSessionIdentity("http://api")).toEqual({
|
||||
slug: "alice",
|
||||
name: "Alice A",
|
||||
email: "a@x.y",
|
||||
});
|
||||
expect(sessionIdentity()).toEqual({
|
||||
slug: "alice",
|
||||
name: "Alice A",
|
||||
email: "a@x.y",
|
||||
});
|
||||
expect(sessionIdentity()).toEqual({ slug: "alice", name: "Alice A" });
|
||||
expect(f).toHaveBeenCalledWith("http://api/api/me", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
|
@ -35,6 +40,7 @@ describe("loadSessionIdentity", () => {
|
|||
expect(await loadSessionIdentity("http://api")).toEqual({
|
||||
slug: "bob",
|
||||
name: "bob@x.y",
|
||||
email: "bob@x.y",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,18 @@ export interface BootOptions {
|
|||
* real bar. `total` is 0 when the server sends no usable Content-Length (or it
|
||||
* disagrees with the decoded stream under gzip/br) — then show bytes, not a %. */
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
/** RAW (decoded) `.wasm` byte count from the release manifest (schema 2), when
|
||||
* known. The progress stream counts DECODED bytes, so this — unlike a
|
||||
* compressed Content-Length — is the correct bar total. */
|
||||
expectedWasmBytes?: number | null;
|
||||
/** The download-completion marker was this bundle's — a previous load finished
|
||||
* downloading it, so the fetch should ride the HTTP cache. Only flips the
|
||||
* status label ("Loading" vs "Downloading"); the mechanics are identical. */
|
||||
warmStart?: boolean;
|
||||
/** The `.wasm` finished downloading AND instantiated — the moment the
|
||||
* download-completion marker is safe to record (a truncated/failed stream
|
||||
* never gets here). */
|
||||
onWasmInstantiated?: () => void;
|
||||
/** Library source backing `window.kicadLibs`. Null/omitted disables libs
|
||||
* (empty lib-tables are seeded). Its libs become sym-lib-table and/or
|
||||
* fp-lib-table rows depending on the tool (see `libKinds` in doBoot). */
|
||||
|
|
@ -172,21 +184,30 @@ function pthreadWorkerScript(
|
|||
* `Content-Length` is the COMPRESSED size when the CDN gzip/br-encodes the wasm,
|
||||
* while the stream yields DECODED bytes — so `loaded` can exceed `total`. The
|
||||
* caller treats `total` as unknown in that case and shows MB rather than a %.
|
||||
* A manifest-provided `expectedBytes` (the RAW size, schema 2) wins over
|
||||
* Content-Length: it's in the same units the stream counts.
|
||||
*
|
||||
* `onDone` fires when the LAST byte has arrived (the compile tail is still
|
||||
* running inside instantiateStreaming) — the boot flips the status to
|
||||
* "Compiling…" there so the post-download freeze reads truthfully.
|
||||
*/
|
||||
async function fetchWasmWithProgress(
|
||||
url: string,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
expectedBytes?: number | null,
|
||||
onDone?: () => void,
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
|
||||
if (!res.body || !onProgress) return res;
|
||||
const total = Number(res.headers.get("content-length")) || 0;
|
||||
const total = expectedBytes || Number(res.headers.get("content-length")) || 0;
|
||||
let loaded = 0;
|
||||
const reader = res.body.getReader();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
onDone?.();
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
|
@ -211,9 +232,15 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
onStatus,
|
||||
onAbort,
|
||||
onProgress,
|
||||
expectedWasmBytes,
|
||||
warmStart,
|
||||
onWasmInstantiated,
|
||||
libsSource,
|
||||
modelsSource,
|
||||
} = opts;
|
||||
// Truthful fetch label: a warm start (download-completion marker present)
|
||||
// reads from the HTTP cache, so "Downloading" would be a lie — and vice versa.
|
||||
const fetchLabel = warmStart ? "Loading the editor…" : "Downloading the editor…";
|
||||
// The deployed bundle backing this tool. footprint_editor/symbol_editor share
|
||||
// the pcbnew/eeschema engine, so their `.wasm`/`.js`/pthread-worker files are the
|
||||
// parent's; `tool` still drives identity (thisProgram), config-seed and lib-kind.
|
||||
|
|
@ -315,7 +342,7 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
onStatus("Downloading…");
|
||||
onStatus(fetchLabel);
|
||||
|
||||
// Prefetch images.tar.gz in parallel with the (much larger) wasm download —
|
||||
// exactly as the harness does. writeResources (in preRun) writes it once ready.
|
||||
|
|
@ -511,7 +538,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
mod.kicadSetDarkChrome?.(currentTheme() === "dark");
|
||||
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
|
||||
if (canvas) canvas.style.display = "block";
|
||||
onStatus("");
|
||||
// The runtime is up; main() is booting the wx UI next (the open-flow's
|
||||
// own "Opening…" statuses take over from there).
|
||||
onStatus("Starting the editor…");
|
||||
},
|
||||
// Resolve wasm + pthread worker against the asset base, not the SPA route.
|
||||
locateFile: (path: string) => `${base}/${path}`,
|
||||
|
|
@ -527,8 +556,14 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
): Record<string, never> => {
|
||||
void (async () => {
|
||||
try {
|
||||
onStatus("Downloading…");
|
||||
const resp = await fetchWasmWithProgress(`${base}/${bundle}.wasm`, onProgress);
|
||||
onStatus(fetchLabel);
|
||||
const resp = await fetchWasmWithProgress(
|
||||
`${base}/${bundle}.wasm`,
|
||||
onProgress,
|
||||
expectedWasmBytes,
|
||||
// Last byte in — only the compile tail is left running.
|
||||
() => onStatus("Compiling…"),
|
||||
);
|
||||
const ct = resp.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/wasm") && WebAssembly.instantiateStreaming) {
|
||||
const { instance, module } = await WebAssembly.instantiateStreaming(
|
||||
|
|
@ -540,9 +575,14 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
// Fallback (no streaming, or a server that mislabels the MIME type):
|
||||
// buffer then compile. Costs peak memory but always works.
|
||||
const bytes = await resp.arrayBuffer();
|
||||
onStatus("Compiling…");
|
||||
const { instance, module } = await WebAssembly.instantiate(bytes, imports);
|
||||
success(instance, module);
|
||||
}
|
||||
// Download + compile both succeeded — safe to record "downloaded"
|
||||
// (a truncated stream would have thrown above).
|
||||
onWasmInstantiated?.();
|
||||
onStatus("Starting KiCad…");
|
||||
} catch (e) {
|
||||
log(`[boot] wasm instantiate failed: ${String(e)}`);
|
||||
onStatus(`Error: ${String(e)}`);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { encodeBundle, sha256Hex, type SyncManifest } from "@pcbjam/shared";
|
||||
import { memStore } from "@pcbjam/sync-client";
|
||||
import { memStore, type LayerStore } from "@pcbjam/sync-client";
|
||||
import { cdnLibsSource } from "./cdn-source";
|
||||
|
||||
const MANIFEST_URL = "https://cdn.test/libs/kicad/9.0.0/manifest.json";
|
||||
|
|
@ -23,7 +23,7 @@ async function makeLib(items: Record<string, string>) {
|
|||
return { manifest, bundle: encodeBundle(manifest, bodies) };
|
||||
}
|
||||
|
||||
async function fakeCdn() {
|
||||
async function fakeCdn(opts?: { sizes?: boolean }) {
|
||||
const device = await makeLib({
|
||||
"symbol/R": "(kicad_symbol_lib (symbol R))",
|
||||
"symbol/C": "(kicad_symbol_lib (symbol C))",
|
||||
|
|
@ -39,6 +39,13 @@ async function fakeCdn() {
|
|||
{ id: "Resistor_SMD", name: "Resistor_SMD", kind: "footprint", itemCount: 1 },
|
||||
],
|
||||
};
|
||||
// Publish-time bundle byte counts (sizes.json) — served unless disabled to
|
||||
// model a tag published before the file existed.
|
||||
const sizes = {
|
||||
schema: 1,
|
||||
tag: "9.0.0",
|
||||
libs: { Device: device.bundle.byteLength, Resistor_SMD: resistors.bundle.byteLength },
|
||||
};
|
||||
const json = (obj: unknown) => ({ ok: true, json: async () => obj });
|
||||
const bin = (bytes: Uint8Array) => ({
|
||||
ok: true,
|
||||
|
|
@ -46,16 +53,25 @@ async function fakeCdn() {
|
|||
});
|
||||
const fetchImpl = (async (url: string) => {
|
||||
if (url === MANIFEST_URL) return json(top);
|
||||
if (url === `${BASE}/sizes.json` && opts?.sizes !== false) return json(sizes);
|
||||
if (url === `${BASE}/Device/manifest`) return json(device.manifest);
|
||||
if (url === `${BASE}/Device/bundle`) return bin(device.bundle);
|
||||
if (url === `${BASE}/Resistor_SMD/manifest`) return json(resistors.manifest);
|
||||
if (url === `${BASE}/Resistor_SMD/bundle`) return bin(resistors.bundle);
|
||||
return { ok: false, status: 404 };
|
||||
}) as unknown as typeof fetch;
|
||||
return cdnLibsSource(MANIFEST_URL, {
|
||||
// Namespace-keyed persistent stores — the "browser's IDB": syncState's
|
||||
// read-only peek must observe the same cache presync/openStack warmed.
|
||||
const stores = new Map<string, LayerStore>();
|
||||
const src = cdnLibsSource(MANIFEST_URL, {
|
||||
fetchImpl,
|
||||
storeFactory: () => memStore(),
|
||||
storeFactory: (ns) => {
|
||||
let s = stores.get(ns);
|
||||
if (!s) stores.set(ns, (s = memStore()));
|
||||
return s;
|
||||
},
|
||||
});
|
||||
return Object.assign(src, { __sizes: sizes });
|
||||
}
|
||||
|
||||
describe("cdn libs source", () => {
|
||||
|
|
@ -137,6 +153,46 @@ describe("cdn libs source", () => {
|
|||
expect(src.saveItemBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("syncState reports cold bytes from sizes.json without downloading", async () => {
|
||||
const src = await fakeCdn();
|
||||
const cold = await src.syncState!("symbol");
|
||||
expect(cold).toEqual({
|
||||
total: 1,
|
||||
warm: 0,
|
||||
coldBytes: src.__sizes.libs.Device,
|
||||
sizesKnown: true,
|
||||
});
|
||||
// The probe itself must not have warmed anything (no bundle fetch).
|
||||
expect((await src.syncState!("symbol"))!.warm).toBe(0);
|
||||
});
|
||||
|
||||
it("syncState sees presync's warmth; the other kind stays cold", async () => {
|
||||
const src = await fakeCdn();
|
||||
await src.presync!({ kind: "symbol" });
|
||||
expect(await src.syncState!("symbol")).toEqual({
|
||||
total: 1,
|
||||
warm: 1,
|
||||
coldBytes: 0,
|
||||
sizesKnown: true,
|
||||
});
|
||||
expect(await src.syncState!("footprint")).toEqual({
|
||||
total: 1,
|
||||
warm: 0,
|
||||
coldBytes: src.__sizes.libs.Resistor_SMD,
|
||||
sizesKnown: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("syncState on a tag without sizes.json: counts right, sizesKnown false", async () => {
|
||||
const src = await fakeCdn({ sizes: false });
|
||||
expect(await src.syncState!("footprint")).toEqual({
|
||||
total: 1,
|
||||
warm: 0,
|
||||
coldBytes: 0,
|
||||
sizesKnown: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("getFpIndex fetches fp-index.json as raw text, null on 404", async () => {
|
||||
const INDEX = { schema: 1, tag: "9.0.0", libs: { Resistor_SMD: [["R_0402_1005Metric", 2]] } };
|
||||
let indexFetches = 0;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { SyncStack, type SyncStackOptions } from "@pcbjam/sync-client";
|
||||
import { peekNamespaces, SyncStack, type SyncStackOptions } from "@pcbjam/sync-client";
|
||||
import { asyncMap } from "@/lib/async-map";
|
||||
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
||||
import type { LibInfo, LibItemInfo, LibsSource, LibsSyncState } from "./source";
|
||||
|
||||
/**
|
||||
* A read-only, no-backend `LibsSource` that serves the FULL default KiCad symbol
|
||||
|
|
@ -14,6 +14,8 @@ import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
|||
* Layout under the manifest's directory (`<cdn>/libs/kicad/<tag>/`):
|
||||
* manifest.json top index: every lib (id, name, kind, itemCount)
|
||||
* fp-index.json footprint index: per-lib [name, uniquePadCount]
|
||||
* sizes.json per-lib bundle byte counts (consent dialog; may
|
||||
* be absent on tags published before it existed)
|
||||
* <libId>/manifest per-lib SyncManifest (GET .../manifest)
|
||||
* <libId>/bundle per-lib bundle: manifest + all bodies (cold init)
|
||||
* Item paths inside a lib follow the `"<kind>/<name>"` scheme.
|
||||
|
|
@ -31,6 +33,11 @@ interface CdnLibsManifest {
|
|||
tag: string;
|
||||
libs: CdnLibEntry[];
|
||||
}
|
||||
interface CdnLibsSizes {
|
||||
schema: number;
|
||||
tag: string;
|
||||
libs: Record<string, number>;
|
||||
}
|
||||
|
||||
export function cdnLibsSource(
|
||||
manifestUrl: string,
|
||||
|
|
@ -74,6 +81,22 @@ export function cdnLibsSource(
|
|||
return manifestP;
|
||||
};
|
||||
|
||||
// Per-lib bundle byte counts (sizes.json). Null when the tag predates the
|
||||
// file — syncState then reports sizesKnown: false. A resolved null is cached:
|
||||
// the consent gate reads this once, and a 404 is a stable answer.
|
||||
let sizesP: Promise<CdnLibsSizes | null> | null = null;
|
||||
const loadSizes = (): Promise<CdnLibsSizes | null> => {
|
||||
return (sizesP ??= (async () => {
|
||||
try {
|
||||
const r = await fetchImpl(`${baseDir}/sizes.json`, { cache: "no-store" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as CdnLibsSizes;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})());
|
||||
};
|
||||
|
||||
// One lazily-opened SyncStack per lib (its IDB store is keyed by namespace, so
|
||||
// a lib is cached once and reused across opens).
|
||||
const stacks = new Map<string, Promise<SyncStack>>();
|
||||
|
|
@ -122,6 +145,26 @@ export function cdnLibsSource(
|
|||
const stack = await openStack(libId);
|
||||
return (await stack.list()).map((e) => splitPath(e.path));
|
||||
},
|
||||
async syncState(kind): Promise<LibsSyncState> {
|
||||
const m = await loadManifest();
|
||||
const libs = m.libs.filter((l) => !kind || l.kind === kind);
|
||||
const [sizes, peeked] = await Promise.all([
|
||||
loadSizes(),
|
||||
// Read-only probe (never downloads); same namespaces openStack uses,
|
||||
// same default "sync:" IDB prefix as the SyncStack it fronts.
|
||||
peekNamespaces(
|
||||
libs.map((l) => `kicad:${m.tag}:${l.id}`),
|
||||
opts?.storeFactory ? { storeFactory: opts.storeFactory } : undefined,
|
||||
),
|
||||
]);
|
||||
let warm = 0;
|
||||
let coldBytes = 0;
|
||||
for (const l of libs) {
|
||||
if (peeked.get(`kicad:${m.tag}:${l.id}`)) warm++;
|
||||
else coldBytes += sizes?.libs[l.id] ?? 0;
|
||||
}
|
||||
return { total: libs.length, warm, coldBytes, sizesKnown: sizes !== null };
|
||||
},
|
||||
async presync(opts): Promise<void> {
|
||||
const { kind, concurrency = 8, onProgress, signal } = opts ?? {};
|
||||
let m: CdnLibsManifest;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ export function remoteLibsSource(
|
|||
description: l.description ?? null,
|
||||
type: l.type,
|
||||
itemCount: l.itemCount,
|
||||
// Cache identity + cold-download bytes (standalone-load-ux 0002) —
|
||||
// absent on backends predating the field.
|
||||
sync: l.sync ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -93,6 +96,7 @@ export function remoteLibsSource(
|
|||
name: res.body.name,
|
||||
description: res.body.description ?? null,
|
||||
type: res.body.type,
|
||||
sync: res.body.sync ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ export interface LibInfo {
|
|||
/** Item count, when the source knows it cheaply (manifest/backend) — shown as a
|
||||
* badge in the home-page library list. Omitted ⇒ no badge. */
|
||||
itemCount?: number;
|
||||
/** The lib's r2-idb-sync cache identity from the backend (wire `libSchema.sync`,
|
||||
* standalone-load-ux 0002): the primary sync layer's namespace (= the local
|
||||
* IDB cache key a cold open hydrates) + expected cold-download bytes (null =
|
||||
* unknown). Powers {@link LibsSource.syncState} on the synced source without
|
||||
* resolving N stacks. Omitted by sources without a client-side sync cache. */
|
||||
sync?: { namespace: string; bytes?: number | null } | null;
|
||||
}
|
||||
|
||||
export interface LibItemInfo {
|
||||
|
|
@ -33,6 +39,19 @@ export interface LibPresyncProgress {
|
|||
current: string;
|
||||
}
|
||||
|
||||
/** What {@link LibsSource.syncState} reports (drives the consent dialog). */
|
||||
export interface LibsSyncState {
|
||||
/** Libs of the requested kind this source serves. */
|
||||
total: number;
|
||||
/** How many of them are already cached locally. */
|
||||
warm: number;
|
||||
/** Bytes still to download to warm the rest (publish-time bundle sizes). */
|
||||
coldBytes: number;
|
||||
/** False when the CDN carries no size info (old tag) — coldBytes is then 0
|
||||
* and meaningless even though cold libs exist. */
|
||||
sizesKnown: boolean;
|
||||
}
|
||||
|
||||
export interface LibsSource {
|
||||
/**
|
||||
* Libraries to expose to the editor (one lib-table row each). `kind` (the
|
||||
|
|
@ -76,6 +95,17 @@ export interface LibsSource {
|
|||
onProgress?: (p: LibPresyncProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* How warm this source's local cache is for `kind`, WITHOUT downloading
|
||||
* anything (a read-only probe — presync's dry-run counterpart). Feeds the
|
||||
* download-consent dialog (standalone-load-ux 0001): `coldBytes` sums the
|
||||
* publish-time bundle sizes of the not-yet-cached libs (0 for libs whose size
|
||||
* the source doesn't know — `sizesKnown` says whether sizes were available at
|
||||
* all). Resolves null when the source can't tell (e.g. a backend that doesn't
|
||||
* expose cache identities) — the dialog then shows the row without figures.
|
||||
* Optional: sources without a client-side cache omit it.
|
||||
*/
|
||||
syncState?(kind?: string): Promise<LibsSyncState | null>;
|
||||
/**
|
||||
* Persist one item body into a writable (user) lib. Optional: read-only
|
||||
* sources omit it (a save into a non-writable source resolves false).
|
||||
|
|
|
|||
|
|
@ -6,8 +6,13 @@ import {
|
|||
type ServerMsg,
|
||||
type SyncManifest,
|
||||
} from "@pcbjam/shared";
|
||||
import { memStore, type RealtimeChannel } from "@pcbjam/sync-client";
|
||||
import { syncedLibsSource } from "./synced-source";
|
||||
import {
|
||||
memStore,
|
||||
type LayerStore,
|
||||
type RealtimeChannel,
|
||||
} from "@pcbjam/sync-client";
|
||||
import { syncedLibsSource, syncedScopeLibsSource } from "./synced-source";
|
||||
import type { LibInfo, LibsSource } from "./source";
|
||||
|
||||
/**
|
||||
* The subscribe → editor-reload bridge (r2-idb-sync task E): a REMOTE change
|
||||
|
|
@ -228,3 +233,88 @@ describe("syncedLibsSource → editor reload bridge", () => {
|
|||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* syncedScopeLibsSource.syncState (standalone-load-ux 0002): warmth from the
|
||||
* LOCAL caches (peekNamespaces on the backend-named namespaces) + cold-byte
|
||||
* sums from the list envelope — no stack resolves, no bundle fetches.
|
||||
*/
|
||||
describe("syncedScopeLibsSource.syncState", () => {
|
||||
function scoped(libs: LibInfo[]) {
|
||||
const remote: LibsSource = {
|
||||
listLibs: async () => libs,
|
||||
listItems: async () => [],
|
||||
getItemBody: async () => null,
|
||||
};
|
||||
const stores = new Map<string, LayerStore>();
|
||||
const storeFactory = (ns: string): LayerStore => {
|
||||
let s = stores.get(ns);
|
||||
if (!s) stores.set(ns, (s = memStore()));
|
||||
return s;
|
||||
};
|
||||
const src = syncedScopeLibsSource(remote, {
|
||||
apiBase: API,
|
||||
scope: "s",
|
||||
storeFactory,
|
||||
});
|
||||
return { src, storeFactory };
|
||||
}
|
||||
|
||||
/** Seed a namespace's store so the peek sees it warm. */
|
||||
async function warmUp(storeFactory: (ns: string) => LayerStore, ns: string) {
|
||||
await storeFactory(ns).setManifest({
|
||||
version: 1,
|
||||
entries: { "symbol/R": { hash: "h", size: 5, mtime: 0 } },
|
||||
});
|
||||
}
|
||||
|
||||
it("counts local warmth + sums the cold libs' envelope bytes", async () => {
|
||||
const { src, storeFactory } = scoped([
|
||||
{ id: "a", name: "A", sync: { namespace: "origin:a@v1", bytes: 1000 } },
|
||||
{ id: "b", name: "B", sync: { namespace: "origin:b@v1", bytes: 2000 } },
|
||||
{ id: "c", name: "C", sync: { namespace: "origin:c@v1", bytes: 4000 } },
|
||||
]);
|
||||
await warmUp(storeFactory, "origin:a@v1");
|
||||
expect(await src.syncState!("symbol")).toEqual({
|
||||
total: 3,
|
||||
warm: 1,
|
||||
coldBytes: 6000,
|
||||
sizesKnown: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a cold lib without a byte figure degrades to sizesKnown: false", async () => {
|
||||
const { src, storeFactory } = scoped([
|
||||
{ id: "a", name: "A", sync: { namespace: "origin:a@v1", bytes: 1000 } },
|
||||
// live org layer: namespace but no stamped size
|
||||
{ id: "u", name: "User", type: "user", sync: { namespace: "org:u", bytes: null } },
|
||||
]);
|
||||
expect(await src.syncState!()).toEqual({
|
||||
total: 2,
|
||||
warm: 0,
|
||||
coldBytes: 1000,
|
||||
sizesKnown: false,
|
||||
});
|
||||
// …but once the unknown-size lib is the WARM one, sizes are known again.
|
||||
await warmUp(storeFactory, "org:u");
|
||||
expect(await src.syncState!()).toEqual({
|
||||
total: 2,
|
||||
warm: 1,
|
||||
coldBytes: 1000,
|
||||
sizesKnown: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null (unknown) when the backend names no namespaces at all", async () => {
|
||||
const { src } = scoped([{ id: "a", name: "A" }]); // pre-`sync`-field backend
|
||||
expect(await src.syncState!()).toBeNull();
|
||||
});
|
||||
|
||||
it("probing never initializes a cold cache", async () => {
|
||||
const { src, storeFactory } = scoped([
|
||||
{ id: "a", name: "A", sync: { namespace: "origin:a@v1", bytes: 1000 } },
|
||||
]);
|
||||
await src.syncState!();
|
||||
expect(await storeFactory("origin:a@v1").getManifest()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { PROJECT_HEADER, SCOPE_HEADER, USER_HEADER } from "@pcbjam/shared";
|
||||
import {
|
||||
peekNamespaces,
|
||||
SyncStack,
|
||||
type ChannelFactory,
|
||||
type LayerDescriptor,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
type LibItemInfo,
|
||||
type LibItemUpdatedDetail,
|
||||
type LibsSource,
|
||||
type LibsSyncState,
|
||||
} from "./source";
|
||||
|
||||
/**
|
||||
|
|
@ -290,6 +292,44 @@ export function syncedScopeLibsSource(
|
|||
listLibs: (kind) => remote.listLibs(kind),
|
||||
createLib: remote.createLib?.bind(remote),
|
||||
getFpIndex: remote.getFpIndex?.bind(remote),
|
||||
async syncState(kind): Promise<LibsSyncState | null> {
|
||||
// Read-only warmth probe for the download-consent gate (standalone-
|
||||
// load-ux 0002): the backend's list envelope names each lib's primary
|
||||
// sync namespace (= its IDB cache key) + expected cold bytes, so warmth
|
||||
// is answerable from the local cache alone — no stack resolves, no
|
||||
// bundle fetches. A backend predating the `sync` field yields no
|
||||
// namespaces at all → null (unknown), never a fake all-cold answer.
|
||||
const libs = await remote.listLibs(kind);
|
||||
const withNs = libs.filter(
|
||||
(l): l is LibInfo & { sync: { namespace: string; bytes?: number | null } } =>
|
||||
!!l.sync?.namespace,
|
||||
);
|
||||
if (withNs.length === 0) return null;
|
||||
const peeked = await peekNamespaces(
|
||||
withNs.map((l) => l.sync.namespace),
|
||||
opts.storeFactory ? { storeFactory: opts.storeFactory } : undefined,
|
||||
);
|
||||
let warm = 0;
|
||||
let coldBytes = 0;
|
||||
// Libs with no namespace at all are cold-with-unknown-size by definition
|
||||
// (they can't be probed and carry no byte figure).
|
||||
let coldUnknown = libs.length - withNs.length;
|
||||
for (const l of withNs) {
|
||||
if (peeked.get(l.sync.namespace)) warm++;
|
||||
else if (typeof l.sync.bytes === "number") coldBytes += l.sync.bytes;
|
||||
else coldUnknown++;
|
||||
}
|
||||
const cold = libs.length - warm;
|
||||
return {
|
||||
total: libs.length,
|
||||
warm,
|
||||
coldBytes,
|
||||
// "Sizes known" = every cold lib had a byte figure. Cold libs WITHOUT
|
||||
// one (live org/mirror layers, unstamped versions) make the MB figure
|
||||
// an undercount, so degrade to the count-only wording instead.
|
||||
sizesKnown: cold > 0 ? coldUnknown === 0 : true,
|
||||
};
|
||||
},
|
||||
listItems: (libId) => forLib(libId).listItems(libId),
|
||||
getAllItems: (libId) => forLib(libId).getAllItems!(libId),
|
||||
getItemBody: (libId, kind, name) =>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Tool } from "@pcbjam/shared";
|
||||
|
||||
// resolveWasmBase reads WASM_ROOT / WASM_MANIFEST_FILE from config at import
|
||||
// resolveWasmBase/Meta read WASM_ROOT / WASM_MANIFEST_FILE from config at import
|
||||
// time, so each case mocks config fresh then dynamically imports the module.
|
||||
async function loadResolver(cfg: {
|
||||
async function loadModule(cfg: {
|
||||
WASM_ROOT: string;
|
||||
WASM_MANIFEST_FILE: string | null;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/config", () => cfg);
|
||||
return (await import("./wasm-assets")).resolveWasmBase;
|
||||
return await import("./wasm-assets");
|
||||
}
|
||||
|
||||
const PCBNEW = "pcbnew" as Tool;
|
||||
|
|
@ -23,11 +23,11 @@ describe("resolveWasmBase", () => {
|
|||
it("uses an explicit override verbatim (trailing slash stripped), no fetch", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const resolve = await loadResolver({
|
||||
const { resolveWasmBase } = await loadModule({
|
||||
WASM_ROOT: "/wasm",
|
||||
WASM_MANIFEST_FILE: "manifest-1.json",
|
||||
});
|
||||
expect(await resolve(PCBNEW, "https://cdn.example/wasm/pcbnew/9/")).toBe(
|
||||
expect(await resolveWasmBase(PCBNEW, "https://cdn.example/wasm/pcbnew/9/")).toBe(
|
||||
"https://cdn.example/wasm/pcbnew/9",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
|
@ -36,32 +36,34 @@ describe("resolveWasmBase", () => {
|
|||
it("returns the flat root when no manifest is configured (dev), no fetch", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const resolve = await loadResolver({
|
||||
const { resolveWasmBase } = await loadModule({
|
||||
WASM_ROOT: "/wasm",
|
||||
WASM_MANIFEST_FILE: null,
|
||||
});
|
||||
expect(await resolve(PCBNEW)).toBe("/wasm");
|
||||
expect(await resolveWasmBase(PCBNEW)).toBe("/wasm");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the per-tool versioned folder from the manifest", async () => {
|
||||
it("resolves the tool's BUNDLE folder from the manifest (pcbnew → kicad_editor)", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
schema: 1,
|
||||
schema: 2,
|
||||
tag: "2.7.7",
|
||||
tools: { pcbnew: "2.7.5", eeschema: "2.7.1" },
|
||||
tools: { kicad_editor: "2.7.5", gerbview: "2.7.1" },
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const resolve = await loadResolver({
|
||||
const { resolveWasmBase } = await loadModule({
|
||||
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
|
||||
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
|
||||
});
|
||||
expect(await resolve(PCBNEW)).toBe("https://cdn.pcbjam.com/wasm/pcbnew/2.7.5");
|
||||
expect(await resolveWasmBase(PCBNEW)).toBe(
|
||||
"https://cdn.pcbjam.com/wasm/kicad_editor/2.7.5",
|
||||
);
|
||||
// Manifest is fetched uncached, and only ONCE across calls (in-memory cached).
|
||||
expect(await resolve("eeschema" as Tool)).toBe(
|
||||
"https://cdn.pcbjam.com/wasm/eeschema/2.7.1",
|
||||
expect(await resolveWasmBase("gerbview" as Tool)).toBe(
|
||||
"https://cdn.pcbjam.com/wasm/gerbview/2.7.1",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
|
|
@ -70,24 +72,175 @@ describe("resolveWasmBase", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("throws when the manifest omits the tool", async () => {
|
||||
it("throws when the manifest omits the tool's bundle", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ tools: {} }) })),
|
||||
);
|
||||
const resolve = await loadResolver({
|
||||
const { resolveWasmBase } = await loadModule({
|
||||
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
|
||||
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
|
||||
});
|
||||
await expect(resolve(PCBNEW)).rejects.toThrow(/no WASM version for "pcbnew"/);
|
||||
await expect(resolveWasmBase(PCBNEW)).rejects.toThrow(
|
||||
/no WASM version for "kicad_editor"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the manifest fetch fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404 })));
|
||||
const resolve = await loadResolver({
|
||||
const { resolveWasmBase } = await loadModule({
|
||||
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
|
||||
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
|
||||
});
|
||||
await expect(resolve(PCBNEW)).rejects.toThrow(/WASM manifest 404/);
|
||||
await expect(resolveWasmBase(PCBNEW)).rejects.toThrow(/WASM manifest 404/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWasmMeta", () => {
|
||||
it("carries the bundle's version + sizes from a schema-2 manifest", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
schema: 2,
|
||||
tag: "2.7.7",
|
||||
tools: { kicad_editor: "2.7.5" },
|
||||
sizes: {
|
||||
kicad_editor: { wasm: 350_000_000, wasmStored: 90_000_000, totalStored: 95_000_000 },
|
||||
},
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const { resolveWasmMeta } = await loadModule({
|
||||
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
|
||||
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
|
||||
});
|
||||
expect(await resolveWasmMeta(PCBNEW)).toEqual({
|
||||
base: "https://cdn.pcbjam.com/wasm/kicad_editor/2.7.5",
|
||||
bundle: "kicad_editor",
|
||||
ver: "2.7.5",
|
||||
sizes: { wasm: 350_000_000, wasmStored: 90_000_000, totalStored: 95_000_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it("has null ver/sizes for schema-1 manifests, overrides and flat dev roots", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ schema: 1, tag: "2.7.7", tools: { kicad_editor: "2.7.5" } }),
|
||||
})),
|
||||
);
|
||||
const { resolveWasmMeta } = await loadModule({
|
||||
WASM_ROOT: "https://cdn.pcbjam.com/wasm",
|
||||
WASM_MANIFEST_FILE: "manifest-2.7.7.json",
|
||||
});
|
||||
// schema 1: version resolves, sizes absent
|
||||
expect((await resolveWasmMeta(PCBNEW)).sizes).toBeNull();
|
||||
// override / flat: no version → the consent gate never runs there
|
||||
expect(await resolveWasmMeta(PCBNEW, "https://x/y")).toMatchObject({
|
||||
ver: null,
|
||||
sizes: null,
|
||||
});
|
||||
const flat = await loadModule({ WASM_ROOT: "/wasm", WASM_MANIFEST_FILE: null });
|
||||
expect(await flat.resolveWasmMeta(PCBNEW)).toMatchObject({ ver: null, sizes: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("download-completion marker", () => {
|
||||
function stubLocalStorage() {
|
||||
const map = new Map<string, string>();
|
||||
const ls = {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("localStorage", ls);
|
||||
return map;
|
||||
}
|
||||
|
||||
it("marks + reads back one bundle/version; a version bump reads cold", async () => {
|
||||
stubLocalStorage();
|
||||
const { isWasmDownloaded, markWasmDownloaded } = await loadModule({
|
||||
WASM_ROOT: "/wasm",
|
||||
WASM_MANIFEST_FILE: null,
|
||||
});
|
||||
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(false);
|
||||
markWasmDownloaded("kicad_editor", "2.7.5");
|
||||
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(true);
|
||||
// content-addressed: a new release is honestly "not downloaded yet"
|
||||
expect(isWasmDownloaded("kicad_editor", "2.8.0")).toBe(false);
|
||||
// flat/override layouts (ver null) never read warm and never write
|
||||
expect(isWasmDownloaded("kicad_editor", null)).toBe(false);
|
||||
markWasmDownloaded("gerbview", null);
|
||||
expect(isWasmDownloaded("gerbview", "any")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps ONE marker per bundle and flags prior versions as an update", async () => {
|
||||
const map = stubLocalStorage();
|
||||
const { hasAnyWasmDownload, isWasmDownloaded, markWasmDownloaded } =
|
||||
await loadModule({ WASM_ROOT: "/wasm", WASM_MANIFEST_FILE: null });
|
||||
expect(hasAnyWasmDownload("kicad_editor")).toBe(false);
|
||||
markWasmDownloaded("kicad_editor", "2.7.5");
|
||||
markWasmDownloaded("gerbview", "2.7.5");
|
||||
// Before re-downloading, the OLD marker makes the consent an "update".
|
||||
expect(hasAnyWasmDownload("kicad_editor")).toBe(true);
|
||||
markWasmDownloaded("kicad_editor", "2.8.0");
|
||||
expect(isWasmDownloaded("kicad_editor", "2.8.0")).toBe(true);
|
||||
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(false); // superseded, dropped
|
||||
expect(isWasmDownloaded("gerbview", "2.7.5")).toBe(true); // other bundles untouched
|
||||
expect([...map.keys()].filter((k) => k.includes("kicad_editor"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats blocked/absent storage as cold and never throws", async () => {
|
||||
// node env: no localStorage global at all
|
||||
const { hasAnyWasmDownload, isWasmDownloaded, markWasmDownloaded } =
|
||||
await loadModule({ WASM_ROOT: "/wasm", WASM_MANIFEST_FILE: null });
|
||||
expect(isWasmDownloaded("kicad_editor", "2.7.5")).toBe(false);
|
||||
expect(hasAnyWasmDownload("kicad_editor")).toBe(false);
|
||||
expect(() => markWasmDownloaded("kicad_editor", "2.7.5")).not.toThrow();
|
||||
});
|
||||
|
||||
it("auto-download preference round-trips and defaults off", async () => {
|
||||
stubLocalStorage();
|
||||
const { autoDownloadEnabled, setAutoDownloadEnabled } = await loadModule({
|
||||
WASM_ROOT: "/wasm",
|
||||
WASM_MANIFEST_FILE: null,
|
||||
});
|
||||
expect(autoDownloadEnabled()).toBe(false);
|
||||
setAutoDownloadEnabled(true);
|
||||
expect(autoDownloadEnabled()).toBe(true);
|
||||
setAutoDownloadEnabled(false);
|
||||
expect(autoDownloadEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWasmStoredSize", () => {
|
||||
it("HEADs the wasm and returns Content-Length; null on failure", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
headers: { get: () => "12345678" },
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { fetchWasmStoredSize } = await loadModule({
|
||||
WASM_ROOT: "/wasm",
|
||||
WASM_MANIFEST_FILE: null,
|
||||
});
|
||||
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBe(12345678);
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://cdn/x/1/kicad_editor.wasm", {
|
||||
method: "HEAD",
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 405 })));
|
||||
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBeNull();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, headers: { get: () => null } })));
|
||||
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBeNull();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(new Error("net"))));
|
||||
expect(await fetchWasmStoredSize("https://cdn/x/1", "kicad_editor")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,11 +13,27 @@ import { TOOL_BUNDLE, type Bundle } from "./constants";
|
|||
* load with no app rebuild). See docs/features/demo-deploy/0001-*.
|
||||
*
|
||||
* No manifest configured (dev / same-origin) ⇒ the flat `WASM_ROOT` layout.
|
||||
*
|
||||
* schema 2 manifests also carry per-bundle `sizes` (standalone-load-ux 0001):
|
||||
* `wasm` = RAW/decoded bytes (matches the boot progress stream's counting),
|
||||
* `wasmStored`/`totalStored` = over-the-wire bytes (post br/gzip) — what the
|
||||
* download consent dialog quotes. Both optional: schema-1 manifests and tools
|
||||
* published before schema 2 carry none/partials.
|
||||
*/
|
||||
export interface WasmBundleSizes {
|
||||
/** Raw (decoded) `.wasm` bytes — the progress bar's true total. */
|
||||
wasm?: number;
|
||||
/** Over-the-wire `.wasm` bytes (compressed). */
|
||||
wasmStored?: number;
|
||||
/** Over-the-wire bytes for the whole bundle (wasm + glue + resources). */
|
||||
totalStored?: number;
|
||||
}
|
||||
|
||||
export interface WasmManifest {
|
||||
schema: number;
|
||||
tag: string;
|
||||
tools: Record<string, string>;
|
||||
sizes?: Record<string, WasmBundleSizes>;
|
||||
}
|
||||
|
||||
let manifestPromise: Promise<WasmManifest> | null = null;
|
||||
|
|
@ -31,6 +47,52 @@ function loadManifest(): Promise<WasmManifest> {
|
|||
})());
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the boot + the download-consent gate need to know about a tool's
|
||||
* deployed bundle:
|
||||
* - `base`: asset base (no trailing slash) for `bootKicadTool({ base })`.
|
||||
* - `bundle`: the deployed bundle backing the tool (see TOOL_BUNDLE).
|
||||
* - `ver`: the bundle's content-addressed version — null in the flat/override
|
||||
* layouts, where there's no versioning (and the gate doesn't run).
|
||||
* - `sizes`: byte counts when the manifest carries them (schema 2), else null.
|
||||
*/
|
||||
export interface WasmMeta {
|
||||
base: string;
|
||||
bundle: Bundle;
|
||||
ver: string | null;
|
||||
sizes: WasmBundleSizes | null;
|
||||
}
|
||||
|
||||
/** A tool may be served by a shared bundle (all four editors → kicad_editor);
|
||||
* a caller may also name a bundle directly (occ_service). */
|
||||
function bundleOf(tool: Tool | Bundle): Bundle {
|
||||
return (TOOL_BUNDLE as Partial<Record<string, Bundle>>)[tool] ?? (tool as Bundle);
|
||||
}
|
||||
|
||||
export async function resolveWasmMeta(
|
||||
tool: Tool | Bundle,
|
||||
override?: string,
|
||||
): Promise<WasmMeta> {
|
||||
const bundle = bundleOf(tool);
|
||||
if (override) {
|
||||
return { base: override.replace(/\/+$/, ""), bundle, ver: null, sizes: null };
|
||||
}
|
||||
if (!WASM_MANIFEST_FILE) {
|
||||
return { base: WASM_ROOT, bundle, ver: null, sizes: null }; // flat (dev)
|
||||
}
|
||||
const manifest = await loadManifest();
|
||||
const ver = manifest.tools?.[bundle];
|
||||
if (!ver) {
|
||||
throw new Error(`no WASM version for "${bundle}" in ${WASM_MANIFEST_FILE}`);
|
||||
}
|
||||
return {
|
||||
base: `${WASM_ROOT}/${bundle}/${ver}`,
|
||||
bundle,
|
||||
ver,
|
||||
sizes: manifest.sizes?.[bundle] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset base (no trailing slash) for `bootKicadTool({ base })`.
|
||||
* - `override` (e.g. an e2e fixture) wins, used verbatim.
|
||||
|
|
@ -41,18 +103,110 @@ export async function resolveWasmBase(
|
|||
tool: Tool | Bundle,
|
||||
override?: string,
|
||||
): Promise<string> {
|
||||
if (override) return override.replace(/\/+$/, "");
|
||||
if (!WASM_MANIFEST_FILE) return WASM_ROOT; // flat (dev / same-origin)
|
||||
// A tool may be served by a shared bundle (all four editors → kicad_editor);
|
||||
// resolve the folder/version of the bundle, not the logical tool (bundles are
|
||||
// the only thing published/listed in the manifest). A caller may also name a
|
||||
// bundle directly (occ_service — a delivery artifact backing no tool).
|
||||
const bundle: Bundle =
|
||||
(TOOL_BUNDLE as Partial<Record<string, Bundle>>)[tool] ?? (tool as Bundle);
|
||||
const manifest = await loadManifest();
|
||||
const ver = manifest.tools?.[bundle];
|
||||
if (!ver) {
|
||||
throw new Error(`no WASM version for "${bundle}" in ${WASM_MANIFEST_FILE}`);
|
||||
}
|
||||
return `${WASM_ROOT}/${bundle}/${ver}`;
|
||||
return (await resolveWasmMeta(tool, override)).base;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- download-completion marker -- */
|
||||
|
||||
// "Have we downloaded this exact bundle before?" The wasm has no explicit cache
|
||||
// — a plain fetch rides the browser HTTP cache, which is unqueryable cross-
|
||||
// origin — so completed downloads are recorded here, keyed by the content-
|
||||
// addressed bundle/version (a release bump changes the key, so a new version
|
||||
// honestly reads as not-downloaded). If the HTTP cache was evicted anyway, the
|
||||
// only cost is a fast-labeled load that shows a real progress bar.
|
||||
const MARKER_PREFIX = "pcbjam:wasm-downloaded:";
|
||||
|
||||
function markerKey(bundle: string, ver: string): string {
|
||||
return `${MARKER_PREFIX}${bundle}/${ver}`;
|
||||
}
|
||||
|
||||
export function isWasmDownloaded(bundle: string, ver: string | null): boolean {
|
||||
if (!ver) return false;
|
||||
try {
|
||||
return localStorage.getItem(markerKey(bundle, ver)) !== null;
|
||||
} catch {
|
||||
return false; // storage blocked ⇒ treat as cold (the safe direction)
|
||||
}
|
||||
}
|
||||
|
||||
export function markWasmDownloaded(bundle: string, ver: string | null): void {
|
||||
if (!ver) return;
|
||||
try {
|
||||
// One marker per bundle: drop superseded versions' markers (their HTTP
|
||||
// cache entries are dead weight anyway once a new version is in use).
|
||||
for (const key of staleMarkerKeys(bundle, ver)) localStorage.removeItem(key);
|
||||
localStorage.setItem(
|
||||
markerKey(bundle, ver),
|
||||
JSON.stringify({ at: new Date().toISOString() }),
|
||||
);
|
||||
} catch {
|
||||
/* storage blocked — next load just re-consents */
|
||||
}
|
||||
}
|
||||
|
||||
function staleMarkerKeys(bundle: string, keepVer: string): string[] {
|
||||
const prefix = `${MARKER_PREFIX}${bundle}/`;
|
||||
const stale: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix) && key !== markerKey(bundle, keepVer)) {
|
||||
stale.push(key);
|
||||
}
|
||||
}
|
||||
return stale;
|
||||
}
|
||||
|
||||
/** A PREVIOUS version of this bundle was downloaded — the consent dialog words
|
||||
* the download as an update rather than a first-time setup. */
|
||||
export function hasAnyWasmDownload(bundle: string): boolean {
|
||||
try {
|
||||
const prefix = `${MARKER_PREFIX}${bundle}/`;
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
if (localStorage.key(i)?.startsWith(prefix)) return true;
|
||||
}
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Over-the-wire `.wasm` size via HEAD, for manifests without `sizes` (schema
|
||||
* 1 / pre-schema-2 tools). Best-effort: null when the CDN rejects HEAD or
|
||||
* sends no usable Content-Length. */
|
||||
export async function fetchWasmStoredSize(
|
||||
base: string,
|
||||
bundle: string,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const r = await fetch(`${base}/${bundle}.wasm`, { method: "HEAD" });
|
||||
if (!r.ok) return null;
|
||||
const n = Number(r.headers.get("content-length"));
|
||||
return n > 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ auto-download preference -- */
|
||||
|
||||
// "Always download without asking" (the consent dialog's checkbox): a device-
|
||||
// wide opt out of the gate — new versions then download silently, as before
|
||||
// the gate existed.
|
||||
const AUTO_DOWNLOAD_KEY = "pcbjam:wasm-autodownload";
|
||||
|
||||
export function autoDownloadEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_DOWNLOAD_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoDownloadEnabled(on: boolean): void {
|
||||
try {
|
||||
if (on) localStorage.setItem(AUTO_DOWNLOAD_KEY, "1");
|
||||
else localStorage.removeItem(AUTO_DOWNLOAD_KEY);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue