3991c2a5cd74

fix: Split Codex screen load from generator bundle (v3.6.41)

- Render Codex recovery-word gate before loading the heavy generator module - Warm generator/WASM in the background with inline load errors and retry - Add regression coverage for heavy module load failure Verified with focused Codex tests, typecheck, full tests, and full build.

time
2026-05-19 20:08 UTC · 5 months ago
machine
koh2
parent
c7effb2bc4b1
8 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/popup/codex/confirmation.ts+11 −0src/popup/codex/generate.ts+1 −12src/popup/views/CodexGenerateView.test.tsx+82 −0src/popup/views/CodexGenerateView.tsx+74 −37
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.40",5+  "version": "3.6.41",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.40",4+  "version": "3.6.41",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.37",3+  "version": "3.6.41",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.37",9+      "version": "3.6.41",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.40",3+  "version": "3.6.41",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/codex/confirmation.ts+11 −0
@@ -0,0 +1,11 @@1+/** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation2+ * gate. Sorted ascending so they read naturally in the prompt. */3+export function pickConfirmationPositions(): [number, number, number] {4+  const positions = Array.from({ length: 12 }, (_, i) => i + 1);5+  for (let i = positions.length - 1; i > 0; i--) {6+    const j = Math.floor(Math.random() * (i + 1));7+    [positions[i], positions[j]] = [positions[j]!, positions[i]!];8+  }9+  const picked = [positions[0]!, positions[1]!, positions[2]!] as const;10+  return [...picked].sort((a, b) => a - b) as [number, number, number];11+}

view file

src/popup/codex/generate.ts+1 −12
@@ -39,6 +39,7 @@3939   sha256Hex,4040 } from "./helpers";4141 import { renderCodexPDF } from "./pdf";42+export { pickConfirmationPositions } from "./confirmation";4243 4344 /**4445  * Per-stripe scanned-chunk bundle. Restore code groups envelopes by stripe@@ -269,18 +270,6 @@269270   }270271   const pages = Math.ceil(totalChunks / CODES_PER_PAGE);271272   return { pages, parityShards: totalParity };272-}273-274-/** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation275- *  gate. Sorted ascending so they read naturally in the prompt. */276-export function pickConfirmationPositions(): [number, number, number] {277-  const positions = Array.from({ length: 12 }, (_, i) => i + 1);278-  for (let i = positions.length - 1; i > 0; i--) {279-    const j = Math.floor(Math.random() * (i + 1));280-    [positions[i], positions[j]] = [positions[j]!, positions[i]!];281-  }282-  const picked = [positions[0]!, positions[1]!, positions[2]!] as const;283-  return [...picked].sort((a, b) => a - b) as [number, number, number];284273 }285274 286275 // ── Internal helpers ────────────────────────────────────────────────────────

view file

src/popup/views/CodexGenerateView.test.tsx+82 −0
@@ -0,0 +1,82 @@1+// @vitest-environment jsdom2+3+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4+5+import { flushView, renderView } from "./view-test-utils";6+7+const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();8+9+vi.mock("../../shared/bridge", () => ({10+  sendMessage: (msg: unknown) => sendMessageMock(msg),11+}));12+13+vi.mock("../components/RecoveryRuneBackground", () => ({14+  RecoveryRuneBackground: () => null,15+}));16+17+vi.mock("../codex/generate", () => {18+  throw new Error("blocked heavy Codex module");19+});20+21+vi.mock("../codex/rs-loader", () => ({22+  getReedSolomon: vi.fn(async () => ({})),23+}));24+25+describe("CodexGenerateView", () => {26+  let dispose: (() => void) | undefined;27+28+  beforeEach(() => {29+    document.body.innerHTML = "";30+    sendMessageMock.mockReset();31+    sendMessageMock.mockResolvedValue({32+      ok: true,33+      data: {34+        slotA: JSON.stringify({ slot: "a" }),35+        slotB: null,36+        lastModified: null,37+        entryCount: 3,38+        recoveryWords: [39+          "abandon", "ability", "able", "about", "above", "absent",40+          "absorb", "abstract", "absurd", "abuse", "access", "accident",41+        ],42+        verifyCode: "PX-TEST-CODE",43+        vaultUnlocked: true,44+      },45+    });46+47+    vi.stubGlobal("chrome", {48+      storage: {49+        local: {50+          set: vi.fn(),51+        },52+      },53+    } as unknown as typeof chrome);54+  });55+56+  afterEach(() => {57+    dispose?.();58+    dispose = undefined;59+    vi.unstubAllGlobals();60+  });61+62+  it("shows the recovery-word gate even if the heavy Codex module fails to load", async () => {63+    const { CodexGenerateView } = await import("./CodexGenerateView");64+    const mounted = renderView(65+      <CodexGenerateView66+        isPro={true}67+        onBack={vi.fn()}68+        onUpgradePro={vi.fn()}69+        onOpenSecurity={vi.fn()}70+        onVaultLocked={vi.fn()}71+      />,72+    );73+    dispose = mounted.dispose;74+75+    await flushView();76+    await flushView();77+78+    expect(mounted.container.textContent).toContain("your recovery words.");79+    expect(mounted.container.textContent).toContain("abandon");80+    expect(mounted.container.textContent).toContain("Failed to load Codex library");81+  });82+});

view file

src/popup/views/CodexGenerateView.tsx+74 −37
@@ -26,12 +26,7 @@2626   CodexVaultSnapshot,2727 } from "../../shared/codex-types";2828 import { STORAGE_KEY_LAST_GENERATED } from "../../shared/codex-types";29-import {30-  estimateCodexPages,31-  generateCodex,32-  pickConfirmationPositions,33-} from "../codex/generate";34-import { getReedSolomon } from "../codex/rs-loader";29+import { pickConfirmationPositions } from "../codex/confirmation";3530 import type { VaultEntry } from "../../shared/types";3631 3732 interface CodexGenerateViewProps {@@ -43,14 +38,25 @@4338 }4439 4540 type Step = "words" | "generating" | "done";41+type CodexGenerateModule = typeof import("../codex/generate");4642 43+let codexGenerateModulePromise: Promise<CodexGenerateModule> | null = null;4744 45+function loadCodexGenerateModule(): Promise<CodexGenerateModule> {46+  codexGenerateModulePromise ??= import("../codex/generate");47+  return codexGenerateModulePromise;48+}49+50+4851 export function CodexGenerateView(props: CodexGenerateViewProps) {4952   const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);5053   const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);5154   const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);5255   const [loading, setLoading] = createSignal(true);5356   const [loadError, setLoadError] = createSignal<string | null>(null);57+  const [libraryLoading, setLibraryLoading] = createSignal(false);58+  const [libraryReady, setLibraryReady] = createSignal(false);59+  const [libraryError, setLibraryError] = createSignal<string | null>(null);5460   const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);5561 5662   const [step, setStep] = createSignal<Step>("words");@@ -100,35 +106,8 @@100106     }101107 102108     setConfirmPositions(pickConfirmationPositions());103-104-    // Pre-warm the Reed-Solomon WASM so load failures surface before the105-    // user completes the word-confirmation flow.106-    try {107-      await getReedSolomon();108-    } catch (err) {109-      setLoadError(110-        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`111-      );112-      setLoading(false);113-      return;114-    }115-116-    if (res.data.slotA) {117-      try {118-        const { pages, parityShards } = await estimateCodexPages(119-          res.data.slotA,120-          res.data.slotB,121-        );122-        setEstimatedPages(pages);123-        setEstimatedParity(parityShards);124-      } catch {125-        // Estimation is informational; failure is non-fatal.126-        setEstimatedPages(null);127-        setEstimatedParity(null);128-      }129-    }130-131109     setLoading(false);110+    void warmCodexLibrary(res.data);132111   });133112 134113   const allCorrect = createMemo(() => {@@ -161,6 +140,48 @@161140     };162141   }163142 143+  async function warmCodexLibrary(144+    snap?: CodexVaultSnapshot,145+  ): Promise<CodexGenerateModule | null> {146+    if (libraryReady()) return loadCodexGenerateModule();147+148+    setLibraryLoading(true);149+    setLibraryError(null);150+    try {151+      const [generateModule, rsModule] = await Promise.all([152+        loadCodexGenerateModule(),153+        import("../codex/rs-loader"),154+      ]);155+      await rsModule.getReedSolomon();156+      setLibraryReady(true);157+158+      if (snap?.slotA) {159+        try {160+          const { pages, parityShards } = await generateModule.estimateCodexPages(161+            snap.slotA,162+            snap.slotB,163+          );164+          setEstimatedPages(pages);165+          setEstimatedParity(parityShards);166+        } catch {167+          // Estimation is informational; failure is non-fatal.168+          setEstimatedPages(null);169+          setEstimatedParity(null);170+        }171+      }172+173+      return generateModule;174+    } catch (err) {175+      codexGenerateModulePromise = null;176+      setLibraryError(177+        `Failed to load Codex library: ${err instanceof Error ? err.message : String(err)}`,178+      );179+      return null;180+    } finally {181+      setLibraryLoading(false);182+    }183+  }184+164185   function codexFilename(): string {165186     const d = new Date();166187     const y = d.getFullYear();@@ -238,7 +259,12 @@238259     setProgressStep("compress");239260 240261     try {241-      const result = await generateCodex(buildDeps(snap), onProgress);262+      const generateModule = await warmCodexLibrary(snap);263+      if (!generateModule) {264+        setStep("words");265+        return;266+      }267+      const result = await generateModule.generateCodex(buildDeps(snap), onProgress);242268       downloadPdf(result.pdfBytes);243269       setCompleted(result.manifest);244270       setStep("done");@@ -434,13 +460,23 @@434460                 {genError()}435461               </p>436462             </Show>463+            <Show when={libraryError()}>464+              <p class="peach-hint peach-hint-error peach-codex-error">465+                {libraryError()}466+              </p>467+            </Show>468+            <Show when={libraryLoading() && !libraryReady()}>469+              <p class="peach-hint peach-codex-meta">470+                preparing Codex library…471+              </p>472+            </Show>437473 438474             <button439475               class="peach-btn peach-btn--accent peach-btn--md peach-btn--full peach-codex-generate-btn"440-              disabled={!allCorrect()}476+              disabled={!allCorrect() || libraryLoading()}441477               onClick={() => void handleGenerate()}442478             >443-              generate my Codex479+              {libraryLoading() && !libraryReady() ? "preparing…" : "generate my Codex"}444480             </button>445481           </Show>446482 @@ -546,6 +582,7 @@546582               onClick={() => {547583                 setCompleted(null);548584                 setGenError(null);585+                setLibraryError(null);549586                 setConfirmInputs(["", "", ""]);550587                 setConfirmPositions(pickConfirmationPositions());551588                 setStep("words");

view file