31c72b10861a

fix: Improve Codex PDF restore parity and shortfall handling (v3.6.95)

- Raise the parity curve to 15% with a 35-shard cap - Harden PDF import scale detection and Aztec cell decoding - Add stripe-level restore progress and a PDF shortfall retry state - Update Codex tests and E2E metrics coverage to match the new shard math

time
2026-05-24 16:45 UTC · 5 months ago
machine
koh2
parent
3845dc12335f
14 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/popup/codex/codex.suite.test.ts+1 −1src/popup/codex/codex.test.ts+6 −6src/popup/codex/generate.test.ts+8 −9src/popup/codex/pdf-import.ts+235 −55src/popup/codex/restore.test.ts+14 −0src/popup/codex/restore.ts+24 −0src/popup/styles/codex.css+26 −0src/popup/views/CodexRestoreView.tsx+105 −27src/shared/codex-types.ts+8 −8tests/codex-pdf-restore.spec.ts+43 −4
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.94",5+  "version": "3.6.95",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.94",4+  "version": "3.6.95",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.93",3+  "version": "3.6.95",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.93",9+      "version": "3.6.95",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.93",3+  "version": "3.6.95",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/codex/codex.suite.test.ts+1 −1
@@ -109,7 +109,7 @@109109   });110110 111111   it("data + parity always equals the requested total for valid inputs", () => {112-    for (const total of [3, 10, 50, 100, 199, 220]) {112+    for (const total of [3, 10, 50, 99, 199, 220]) {113113       const { dataShards, parityShards } = deriveShardCounts(total);114114       expect(dataShards + parityShards).toBe(total);115115     }

view file

src/popup/codex/codex.test.ts+6 −6
@@ -77,9 +77,9 @@7777 describe('Reed-Solomon erasure coding', () => {7878   it('encodes without error', async () => {7979     const data = crypto.getRandomValues(new Uint8Array(10_000));80-    const { dataChunks, parityChunks } = await encodeWithErasure(data);80+    const { dataChunks, parityChunks, parityShards } = await encodeWithErasure(data);8181     expect(dataChunks.length).toBeGreaterThan(0);82-    expect(parityChunks.length).toBe(2);82+    expect(parityChunks.length).toBe(parityShards);8383   });8484 8585   it('reconstructs from all chunks', async () => {@@ -118,13 +118,13 @@118118 119119   it('throws CodexError when too many chunks missing', async () => {120120     const original = crypto.getRandomValues(new Uint8Array(10_000));121-    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);121+    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } = await encodeWithErasure(original);122122     const allChunks = new Map<number, Uint8Array>();123123     dataChunks.forEach((c, i) => allChunks.set(i, c));124124     parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));125-    allChunks.delete(0);126-    allChunks.delete(1);127-    allChunks.delete(2);125+    for (let i = 0; i <= parityShards; i++) {126+      allChunks.delete(i);127+    }128128     await expect(decodeWithErasure(allChunks, dataShards, totalChunks)).rejects.toThrow(CodexError);129129   });130130 });

view file

src/popup/codex/generate.test.ts+8 −9
@@ -261,19 +261,18 @@261261     expect(computeParityShards(1)).toBe(2);262262     expect(computeParityShards(10)).toBe(2);263263     expect(computeParityShards(13)).toBe(2);264-    expect(computeParityShards(40)).toBe(2);265264   });266265 267-  it("computeParityShards scales at 5% for medium vaults", () => {268-    expect(computeParityShards(56)).toBe(3); // 56 * 0.05 = 2.8 → ceil = 3269-    expect(computeParityShards(100)).toBe(5);270-    expect(computeParityShards(213)).toBe(11);266+  it("computeParityShards scales at 15% for medium vaults", () => {267+    expect(computeParityShards(56)).toBe(9); // 56 * 0.15 = 8.4 → ceil = 9268+    expect(computeParityShards(100)).toBe(15);269+    expect(computeParityShards(213)).toBe(32);271270   });272271 273-  it("computeParityShards caps at 20 for large vaults", () => {274-    expect(computeParityShards(600)).toBe(20);275-    expect(computeParityShards(660)).toBe(20);276-    expect(computeParityShards(1000)).toBe(20);272+  it("computeParityShards caps at 35 for large vaults", () => {273+    expect(computeParityShards(600)).toBe(35);274+    expect(computeParityShards(660)).toBe(35);275+    expect(computeParityShards(1000)).toBe(35);277276   });278277 279278   it("encodeWithErasure returns scaled parityShards", async () => {

view file

src/popup/codex/pdf-import.ts+235 −55
@@ -34,6 +34,7 @@3434 3535 import {3636   CODES_PER_COL,37+  CODES_PER_PAGE,3738   CODES_PER_ROW,3839   type ChunkEnvelope,3940 } from "../../shared/codex-types";@@ -56,6 +57,7 @@5657  * the parser surface (parseChunkEnvelope) can stay importable from tests.5758  */5859 type PdfJsModule = typeof import("pdfjs-dist");60+type PdfPage = any;5961 let pdfJsPromise: Promise<PdfJsModule> | null = null;6062 6163 async function getPdfJs(): Promise<PdfJsModule> {@@ -111,51 +113,72 @@111113  */112114 const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;113115 116+interface CellDecodeStrategy {117+  cropFactor: number;118+  normalize: boolean;119+  upscale: number;120+}121+122+const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [123+  { cropFactor: 1.0, normalize: false, upscale: 1.0 },124+  { cropFactor: 0.88, normalize: false, upscale: 1.0 },125+  { cropFactor: 1.12, normalize: false, upscale: 1.0 },126+  { cropFactor: 1.0, normalize: true, upscale: 1.0 },127+  { cropFactor: 0.88, normalize: true, upscale: 1.0 },128+  { cropFactor: 1.0, normalize: false, upscale: 2.0 },129+  { cropFactor: 1.0, normalize: true, upscale: 2.0 },130+];131+132+const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [133+  { cropFactor: 1.0, normalize: false, upscale: 1.0 },134+  { cropFactor: 1.12, normalize: false, upscale: 1.0 },135+  { cropFactor: 0.88, normalize: false, upscale: 1.0 },136+  { cropFactor: 1.0, normalize: true, upscale: 1.0 },137+  { cropFactor: 1.12, normalize: true, upscale: 1.0 },138+  { cropFactor: 1.0, normalize: false, upscale: 2.0 },139+  { cropFactor: 1.0, normalize: true, upscale: 2.0 },140+];141+142+function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {143+  return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;144+}145+114146 /**115- * Probe page 1 of `pdf` at every candidate scale and return the scale that116- * decodes the most codes. "First scale that decodes anything" is not117- * sufficient — scale 5.25 can occasionally decode 1–2 codes from a 6.05118- * (117-module) Codex, producing a false-positive that makes the full import119- * use the wrong scale. Taking the maximum across all candidates avoids this:120- * the correct scale always wins by a large margin (30+ codes vs 0–2).147+ * Probe up to three pages of `pdf` at every candidate scale and return the148+ * scale that decodes the highest fraction of codes. "First scale that decodes149+ * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes150+ * from a 6.05 (117-module) Codex, producing a false-positive that makes the151+ * full import use the wrong scale. Taking the maximum across multiple pages152+ * avoids this: the correct scale wins by a large margin.121153  *122- * Early exit: once a scale decodes a full row's worth of codes (CODES_PER_ROW)123- * we have a confident match and stop probing — this keeps detection fast for124- * the common case where the first or second candidate is correct.154+ * Early exit: once a scale achieves a perfect probe score we can stop — no155+ * later candidate can beat 100% decode rate on the sampled pages.125156  */126157 async function detectRenderScale(127158   pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,128159 ): Promise<number> {129-  let page: Awaited<ReturnType<typeof pdf.getPage>>;130-  try {131-    page = await pdf.getPage(1);132-  } catch {133-    return CANDIDATE_SCALES[0];134-  }135-136160   let bestScale: number = CANDIDATE_SCALES[0];137-  let bestCount = 0;161+  let bestRate = -1;162+  const probePages = Math.min(3, pdf.numPages);138163 139164   for (const scale of CANDIDATE_SCALES) {140165     try {141-      const viewport = page.getViewport({ scale });142-      const canvas = document.createElement("canvas");143-      canvas.width = Math.ceil(viewport.width);144-      canvas.height = Math.ceil(viewport.height);145-      const ctx = canvas.getContext("2d");146-      if (!ctx) continue;147-      ctx.fillStyle = "white";148-      ctx.fillRect(0, 0, canvas.width, canvas.height);149-      await page.render({ canvas, canvasContext: ctx, viewport }).promise;150-151-      const probe = new Set<string>();152-      const found = await scanCanvasGrid(canvas, probe);153-      if (found.length > bestCount) {154-        bestCount = found.length;166+      let decoded = 0;167+      let total = 0;168+      for (let pageNum = 1; pageNum <= probePages; pageNum++) {169+        const page = await pdf.getPage(pageNum);170+        const result = await probePageDecodeRate(page, scale);171+        decoded += result.decoded;172+        total += result.total;173+      }174+      const rate = total > 0 ? decoded / total : 0;175+      if (rate > bestRate) {176+        bestRate = rate;155177         bestScale = scale;156178       }157-      // A full row of codes decoded = unambiguous match; no need to probe further.158-      if (found.length >= CODES_PER_ROW) break;179+      if (rate >= 0.99) {180+        return scale;181+      }159182     } catch {160183       // continue to next candidate161184     }@@ -164,14 +187,58 @@164187   return bestScale;165188 }166189 190+async function probePageDecodeRate(191+  page: PdfPage,192+  scale: number,193+): Promise<{ decoded: number; total: number }> {194+  const canvas = await renderPageToCanvas(page, scale);195+  const ctx = canvas.getContext("2d");196+  if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };197+198+  let decoded = 0;199+  for (let row = 0; row < CODES_PER_COL; row++) {200+    for (let col = 0; col < CODES_PER_ROW; col++) {201+      const rect = cellRectFor(canvas.width, canvas.height, row, col);202+      if (!rect.sw || !rect.sh) continue;203+      const text = decodeAztecCellRobust(204+        canvas,205+        ctx,206+        { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },207+        scale,208+      );209+      if (text !== null) decoded++;210+    }211+  }212+  return { decoded, total: CODES_PER_PAGE };213+}214+215+async function renderPageToCanvas(216+  page: PdfPage,217+  scale: number,218+): Promise<HTMLCanvasElement> {219+  const viewport = page.getViewport({ scale });220+  const canvas = document.createElement("canvas");221+  canvas.width = Math.ceil(viewport.width);222+  canvas.height = Math.ceil(viewport.height);223+  const ctx = canvas.getContext("2d");224+  if (!ctx) {225+    return canvas;226+  }227+  ctx.fillStyle = "white";228+  ctx.fillRect(0, 0, canvas.width, canvas.height);229+  await page.render({ canvas, canvasContext: ctx, viewport }).promise;230+  return canvas;231+}232+167233 /**168234  * Import a Peach Codex PDF and decode every Aztec code it contains.169235  *170- * Automatically detects the render scale by probing page 1 at each candidate171- * scale in CANDIDATE_SCALES — the scale that decodes at least one code is used172- * for all pages. This makes the importer backward-compatible with Codexes173- * generated at any prior CHUNK_SIZE, and forward-compatible with future174- * changes, without requiring any version metadata in the PDF.236+ * Automatically detects the render scale by probing up to three pages at237+ * each candidate scale in CANDIDATE_SCALES — the scale with the highest238+ * decode rate is used for all pages. This makes the importer239+ * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and240+ * forward-compatible with future changes, without requiring any version241+ * metadata in the PDF.175242  */176243 export async function importCodexPDF(177244   file: File,@@ -209,25 +276,14 @@209276 210277     try {211278       const page = await pdf.getPage(pageNum);212-      const viewport = page.getViewport({ scale: renderScale });213-214-      const canvas = document.createElement("canvas");215-      canvas.width = Math.ceil(viewport.width);216-      canvas.height = Math.ceil(viewport.height);279+      const canvas = await renderPageToCanvas(page, renderScale);217280       const ctx = canvas.getContext("2d");218281       if (!ctx) {219282         errors.push(`Page ${pageNum}: no canvas context`);220283         continue;221284       }222285 223-      // pdf.js requires the canvas to be filled before render to avoid the224-      // transparent-on-transparent dark mode regression in v4 — paint white.225-      ctx.fillStyle = "white";226-      ctx.fillRect(0, 0, canvas.width, canvas.height);227-228-      await page.render({ canvas, canvasContext: ctx, viewport }).promise;229-230-      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);286+      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);231287       for (const env of pageEnvelopes) envelopes.push(env);232288 233289       if (totalExpected === null && pageEnvelopes.length > 0) {@@ -238,6 +294,39 @@238294     }239295   }240296 297+  const metricsEnabled =298+    typeof window !== "undefined" &&299+    (import.meta.env.MODE === "test" ||300+      new URLSearchParams(window.location.search).has("codexMetrics"));301+  if (metricsEnabled) {302+    const stripeTotals = new Map<number, number>();303+    const stripeDecoded = new Map<number, number>();304+    for (const env of envelopes) {305+      const stripe = env.v === 3 ? (env.s ?? 0) : 0;306+      stripeTotals.set(stripe, env.t);307+      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);308+    }309+    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);310+    const stripeIndices = Array.from(stripeTotals.keys());311+    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;312+    (window as Window & {313+      __codexImportMetrics?: {314+        decodeRate: number;315+        shortStripeCount: number;316+        perStripeDecoded: number[];317+      };318+    }).__codexImportMetrics = {319+      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,320+      shortStripeCount: Array.from(stripeTotals.entries()).filter(321+        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,322+      ).length,323+      perStripeDecoded:324+        maxStripe >= 0325+          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)326+          : [],327+    };328+  }329+241330   return {242331     chunksFound: envelopes.length,243332     totalExpected,@@ -277,6 +366,7 @@277366 async function scanCanvasGrid(278367   canvas: HTMLCanvasElement,279368   seenIndices: Set<string>,369+  renderScale: number,280370 ): Promise<ChunkEnvelope[]> {281371   const found: ChunkEnvelope[] = [];282372 @@ -300,10 +390,12 @@300390       cellCtx.fillRect(0, 0, sw, sh);301391       cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);302392 303-      const imageData = cellCtx.getImageData(0, 0, sw, sh);304-      const argb = imageDataToArgb(imageData);305-306-      const text = decodeAztecFromArgb(argb, sw, sh);393+      const text = decodeAztecCellRobust(394+        cellCanvas,395+        cellCtx,396+        { x: 0, y: 0, w: sw, h: sh },397+        renderScale,398+      );307399       if (!text) continue;308400 309401       const envelope = parseChunkEnvelope(text);@@ -318,6 +410,94 @@318410   }319411 320412   return found;413+}414+415+function decodeAztecCellRobust(416+  canvas: HTMLCanvasElement,417+  ctx: CanvasRenderingContext2D,418+  cellRect: { x: number; y: number; w: number; h: number },419+  renderScale: number,420+): string | null {421+  const strategies = getCellDecodeStrategies(renderScale);422+  for (const strategy of strategies) {423+    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);424+    if (result !== null) {425+      return result;426+    }427+  }428+  return null;429+}430+431+function decodeAztecCellWithStrategy(432+  canvas: HTMLCanvasElement,433+  ctx: CanvasRenderingContext2D,434+  cellRect: { x: number; y: number; w: number; h: number },435+  strategy: CellDecodeStrategy,436+): string | null {437+  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);438+  if (w <= 0 || h <= 0) return null;439+440+  const imageData = ctx.getImageData(x, y, w, h);441+  const processedData = strategy.normalize442+    ? normalizeToBlackWhite(imageData)443+    : imageData;444+445+  const argb = imageDataToArgb(processedData);446+  const { buffer, width, height } =447+    strategy.upscale > 1448+      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)449+      : { buffer: argb, width: processedData.width, height: processedData.height };450+451+  return decodeAztecFromArgb(buffer, width, height);452+}453+454+function applyCropFactor(455+  rect: { x: number; y: number; w: number; h: number },456+  factor: number,457+  canvasW: number,458+  canvasH: number,459+): { x: number; y: number; w: number; h: number } {460+  const cx = rect.x + rect.w / 2;461+  const cy = rect.y + rect.h / 2;462+  const nw = Math.max(1, Math.round(rect.w * factor));463+  const nh = Math.max(1, Math.round(rect.h * factor));464+  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));465+  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));466+  const w = Math.max(1, Math.min(canvasW - x, nw));467+  const h = Math.max(1, Math.min(canvasH - y, nh));468+  return { x, y, w, h };469+}470+471+function normalizeToBlackWhite(imageData: ImageData): ImageData {472+  const data = new Uint8ClampedArray(imageData.data);473+  for (let i = 0; i < data.length; i += 4) {474+    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;475+    const v = lum > 128 ? 255 : 0;476+    data[i] = v;477+    data[i + 1] = v;478+    data[i + 2] = v;479+    data[i + 3] = 255;480+  }481+  return new ImageData(data, imageData.width, imageData.height);482+}483+484+function upscaleArgb(485+  argb: Int32Array,486+  width: number,487+  height: number,488+  scale: number,489+): { buffer: Int32Array; width: number; height: number } {490+  const targetW = Math.max(1, Math.round(width * scale));491+  const targetH = Math.max(1, Math.round(height * scale));492+  const out = new Int32Array(targetW * targetH);493+  for (let y = 0; y < targetH; y++) {494+    const srcY = Math.min(height - 1, Math.floor(y / scale));495+    for (let x = 0; x < targetW; x++) {496+      const srcX = Math.min(width - 1, Math.floor(x / scale));497+      out[y * targetW + x] = argb[srcY * width + srcX]!;498+    }499+  }500+  return { buffer: out, width: targetW, height: targetH };321501 }322502 323503 /**

view file

src/popup/codex/restore.test.ts+14 −0
@@ -201,6 +201,20 @@201201     expect(map.size).toBe(1);202202     expect(Array.from(map.get(0)!)).toEqual([1, 2, 3]);203203   });204+205+  it("stripeReadiness exposes per-stripe counts", async () => {206+    const buf = new ChunkBuffer();207+    const env = await makeSingleEnvelope({ index: 0, total: 5 });208+    await buf.add(env);209+    expect(buf.stripeReadiness()).toEqual([210+      {211+        stripe: 0,212+        found: 1,213+        required: deriveShardCounts(5).dataShards,214+        isReady: false,215+      },216+    ]);217+  });204218 });205219 206220 describe("parseRawScan", () => {

view file

src/popup/codex/restore.ts+24 −0
@@ -78,6 +78,13 @@7878   allStripesObserved: boolean;7979 }8080 81+export interface StripeReadiness {82+  stripe: number;83+  found: number;84+  required: number;85+  isReady: boolean;86+}87+8188 export class ChunkBuffer {8289   private verifyCode: string | null = null;8390   private stripeCount: number | null = null;@@ -188,6 +195,23 @@188195       if (!s || s.chunks.size < s.dataShards) return false;189196     }190197     return true;198+  }199+200+  stripeReadiness(): StripeReadiness[] {201+    if (this.stripeCount === null) return [];202+    const out: StripeReadiness[] = [];203+    for (let i = 0; i < this.stripeCount; i++) {204+      const s = this.stripes.get(i);205+      const found = s?.chunks.size ?? 0;206+      const required = s?.dataShards ?? 0;207+      out.push({208+        stripe: i,209+        found,210+        required,211+        isReady: !!s && found >= required,212+      });213+    }214+    return out;191215   }192216 193217   entries(): ScannedChunk[] {

view file

src/popup/styles/codex.css+26 −0
@@ -282,6 +282,12 @@282282   margin: 4px 0 0;283283 }284284 285+.peach-codex-worst-stripe {286+  margin-top: -2px;287+  color: var(--peach-t3);288+  font-size: 0.6875rem;289+}290+285291 .peach-codex-passphrase-group {286292   padding: 12px;287293   display: flex;@@ -445,6 +451,26 @@445451   gap: 8px;446452   padding: 16px 0;447453   align-items: stretch;454+}455+456+.peach-codex-pdf-shortfall {457+  display: flex;458+  flex-direction: column;459+  gap: 10px;460+  padding: 14px 12px;461+}462+463+.peach-codex-pdf-shortfall-copy {464+  margin: 0;465+  font-size: 0.75rem;466+  color: var(--peach-t2);467+  line-height: 1.5;468+}469+470+.peach-codex-pdf-shortfall-actions {471+  display: flex;472+  gap: 8px;473+  flex-wrap: wrap;448474 }449475 450476 /* ── Live Aztec gallery (generating step) ─────────────────────────────────── */

view file

src/popup/views/CodexRestoreView.tsx+105 −27
@@ -33,6 +33,7 @@3333   | "choose"3434   | "scanning"3535   | "pdf-import"36+  | "pdf-shortfall"3637   | "ready"3738   | "restoring"3839   | "success"@@ -50,7 +51,6 @@5051 5152   const [phase, setPhase] = createSignal<Phase>("choose");5253   const [scanned, setScanned] = createSignal(0);53-  const [totalShards, setTotalShards] = createSignal<number | null>(null);5454   const [dataShards, setDataShards] = createSignal<number | null>(null);5555   const [verifyCode, setVerifyCode] = createSignal<string | null>(null);5656   const [hint, setHint] = createSignal(@@ -63,6 +63,12 @@6363     total: number;6464   } | null>(null);6565   const [pdfError, setPdfError] = createSignal<string | null>(null);66+  const [pdfShortfall, setPdfShortfall] = createSignal<{67+    found: number;68+    missing: number;69+    ready: number;70+    total: number;71+  } | null>(null);6672   // Hold the reconstructed payload between the integrity check and the SW6773   // call so the user can re-enter the passphrase on a wrong-password failure6874   // without rescanning every code.@@ -77,13 +83,16 @@7783     setScanned(buffer.size());7884     const ctx = buffer.getContext();7985     if (ctx) {80-      setTotalShards(ctx.total);8186       setDataShards(ctx.dataShards);8287       setVerifyCode(ctx.verifyCode);8388     }84-    if (buffer.isReady() && (phase() === "scanning" || phase() === "pdf-import")) {89+    if (90+      buffer.isReady() &&91+      (phase() === "scanning" || phase() === "pdf-import" || phase() === "pdf-shortfall")92+    ) {8593       setPhase("ready");8694       setHint("scan complete. enter your master password to restore.");95+      setPdfShortfall(null);8796       // Stop the camera the instant we have enough codes — no reason to keep8897       // the green light on while the user types their passphrase.8998       if (scanner) {@@ -149,6 +158,7 @@149158   async function startCameraScan() {150159     setPhase("scanning");151160     setCameraError(null);161+    setPdfShortfall(null);152162     // Solid hasn't rendered the <video> element yet — defer until next tick.153163     await Promise.resolve();154164     if (!videoEl) return;@@ -221,6 +231,7 @@221231     setPhase("pdf-import");222232     setPdfProgress(null);223233     setPdfError(null);234+    setPdfShortfall(null);224235 225236     try {226237       // Dynamic import so the popup never pulls pdf.js into the camera-only@@ -244,13 +255,20 @@244255       refreshFromBuffer();245256 246257       if (!buffer.isReady()) {247-        // Some codes are missing — let the user supplement with the camera.248-        setPdfError(249-          `found ${buffer.size()}${250-            result.totalExpected ? ` of ${result.totalExpected}` : ""251-          } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,258+        const stripes = buffer.stripeReadiness();259+        const ready = stripes.filter((s) => s.isReady).length;260+        const missing = stripes.reduce(261+          (sum, s) => sum + Math.max(0, s.required - s.found),262+          0,252263         );253-        await startCameraScan();264+        setPdfShortfall({265+          found: buffer.size(),266+          missing,267+          ready,268+          total: stripes.length,269+        });270+        setPhase("pdf-shortfall");271+        return;254272       }255273     } catch (err) {256274       setPdfError(@@ -260,6 +278,11 @@260278     }261279   }262280 281+  function retryPdfImport() {282+    setPdfShortfall(null);283+    void openPdfPicker();284+  }285+263286   // Detect whether we are running inside a chrome.windows.create() window264287   // that was opened specifically to handle the PDF picker. The action265288   // popup dismisses on focus loss, so the file dialog tears it down. When@@ -366,11 +389,33 @@366389   }367390 368391   function progressPercent(): number {392+    const stripes = buffer.stripeReadiness();393+    if (stripes.length > 0) {394+      const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);395+      if (worst.required > 0) {396+        return Math.min(100, Math.round((worst.found / worst.required) * 100));397+      }398+    }369399     const ds = dataShards();370400     if (!ds || ds <= 0) return 0;371401     return Math.min(100, Math.round((scanned() / ds) * 100));372402   }373403 404+  function stripeProgressSummary():405+    | { ready: number; total: number; worstFound: number; worstRequired: number }406+    | null {407+    const stripes = buffer.stripeReadiness();408+    if (stripes.length === 0) return null;409+    const ready = stripes.filter((s) => s.isReady).length;410+    const worst = stripes.reduce((min, s) => (s.found < min.found ? s : min), stripes[0]!);411+    return {412+      ready,413+      total: stripes.length,414+      worstFound: worst.found,415+      worstRequired: worst.required,416+    };417+  }418+374419   return (375420     <div class="peach-overlay-view ov-enter-right" style="display:flex">376421       <div class="peach-overlay-header">@@ -443,14 +488,6 @@443488               </div>444489             </button>445490 446-            <input447-              ref={(el) => (pdfFileInput = el)}448-              type="file"449-              accept=".pdf,application/pdf"450-              style={{ display: "none" }}451-              onChange={(e) => void handlePDFSelected(e)}452-            />453-454491             <p class="peach-codex-restore-guidance">455492               have both the printed pages and the PDF? the PDF import is456493               faster and more reliable — use it if you can.@@ -464,6 +501,43 @@464501           </div>465502         </Show>466503 504+        <input505+          ref={(el) => (pdfFileInput = el)}506+          type="file"507+          accept=".pdf,application/pdf"508+          style={{ display: "none" }}509+          onChange={(e) => void handlePDFSelected(e)}510+        />511+512+        <Show when={phase() === "pdf-shortfall"}>513+          <div class="peach-settings-group peach-codex-pdf-shortfall">514+            <p class="peach-codex-restore-headline">pdf import needs another pass.</p>515+            <p class="peach-codex-pdf-shortfall-copy">516+              The PDF decoded {pdfShortfall()?.found ?? 0} codes, but {pdfShortfall()?.missing ?? 0} remained unreadable.517+              {pdfShortfall()?.ready === pdfShortfall()?.total518+                ? " all stripes are ready now."519+                : ` ${pdfShortfall()?.ready ?? 0} of ${pdfShortfall()?.total ?? 0} stripes are ready.`}520+            </p>521+            <p class="peach-codex-pdf-shortfall-copy">522+              try importing the PDF again to fill the gaps, or switch to camera scanning if it keeps failing.523+            </p>524+            <div class="peach-codex-pdf-shortfall-actions">525+              <button526+                class="peach-btn peach-btn--accent peach-btn--md"527+                onClick={() => retryPdfImport()}528+              >529+                try PDF again530+              </button>531+              <button532+                class="peach-btn peach-btn--ghost peach-btn--md"533+                onClick={() => void startCameraScan()}534+              >535+                use camera536+              </button>537+            </div>538+          </div>539+        </Show>540+467541         <Show468542           when={469543             phase() === "scanning" ||@@ -525,20 +599,24 @@525599               />526600             </div>527601             <p class="peach-codex-progress-label">528-              <Show529-                when={totalShards() !== null}530-                fallback={<span>{scanned()} codes scanned</span>}531-              >532-                <span>533-                  {scanned()} / {dataShards()} codes534-                </span>602+              <Show when={stripeProgressSummary()} fallback={<span>{scanned()} codes scanned</span>}>603+                {(summary) => (604+                  <span>605+                    {summary().ready} / {summary().total} stripes ready606+                  </span>607+                )}535608               </Show>536609               <Show when={verifyCode()}>537-                <span class="peach-codex-verify-tag">538-                  {verifyCode()}539-                </span>610+                <span class="peach-codex-verify-tag">{verifyCode()}</span>540611               </Show>541612             </p>613+            <Show when={stripeProgressSummary()}>614+              {(summary) => (615+                <p class="peach-codex-hint peach-codex-worst-stripe">616+                  least-ready stripe: {summary().worstFound} / {summary().worstRequired} codes617+                </p>618+              )}619+            </Show>542620             <p class="peach-codex-hint">{hint()}</p>543621             <Show when={scanWarning()}>544622               <p class="peach-hint peach-hint--warning peach-codex-warning">

view file

src/shared/codex-types.ts+8 −8
@@ -194,8 +194,8 @@194194 /**195195  * Maximum data shards per RS stripe. The underlying reed-solomon-erasure196196  * library operates over GF(2⁸), which hard-caps total shards (data + parity)197- * at 256. Holding data at 200 leaves room for the maximum 20-parity case198- * (200 + 20 = 220, well under the cap) and is the threshold above which we197+ * at 256. Holding data at 200 leaves room for the maximum 35-parity case198+ * (200 + 35 = 235, well under the cap) and is the threshold above which we199199  * split the compressed payload into multiple independent stripes.200200  */201201 export const MAX_DATA_SHARDS_PER_STRIPE = 200;@@ -204,10 +204,10 @@204204 export const CODEX_MIN_PARITY_SHARDS = 2;205205 206206 /** Ceiling on parity shards: caps printing overhead for very large vaults. */207-export const CODEX_MAX_PARITY_SHARDS = 20;207+export const CODEX_MAX_PARITY_SHARDS = 35;208208 209-/** Target ratio of parity to data shards (5%). */210-export const CODEX_PARITY_RATIO = 0.05;209+/** Target ratio of parity to data shards (15%). */210+export const CODEX_PARITY_RATIO = 0.15;211211 212212 /**213213  * Number of parity shards for a Codex with `dataShards` data shards. Scales@@ -218,9 +218,9 @@218218  *219219  * Examples:220220  *   13 → 2   (minimum floor)221- *   56 → 3   (5% bracket)222- *   213 → 11223- *   660 → 20 (maximum cap)221+ *   56 → 9   (15% bracket)222+ *   213 → 32223+ *   660 → 35 (maximum cap)224224  */225225 export function computeParityShards(dataShards: number): number {226226   return Math.min(

view file

tests/codex-pdf-restore.spec.ts+43 −4
@@ -28,12 +28,14 @@2828 import { dirname, join } from "path";2929 import os from "os";3030 import fs from "fs/promises";31+import { existsSync } from "fs";3132 3233 const __filename = fileURLToPath(import.meta.url);3334 const __dirname = dirname(__filename);3435 const EXTENSION_PATH = join(__dirname, "..", "dist");3536 3637 const CODEX_PDF_PATH = "/Users/june/Downloads/peach-codex-2026-05-20 (2).pdf";38+const CURRENT_GEN_PDF_PATH = join(__dirname, "fixtures", "peach-codex-current-gen.pdf");3739 3840 async function makeContext(): Promise<BrowserContext> {3941   const { chromium } = await import("playwright");@@ -60,7 +62,11 @@6062   return new URL(sw.url()).host;6163 }6264 63-async function openCodexRestorePage(ctx: BrowserContext, extId: string): Promise<Page> {65+async function openCodexRestorePage(66+  ctx: BrowserContext,67+  extId: string,68+  search = "codex=restore",69+): Promise<Page> {6470   const page = await ctx.newPage();6571 6672   // Capture extension console output for diagnostics.@@ -74,10 +80,10 @@7480   // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true.7581   // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden7682   // file input, opening the OS dialog which steals focus and kills the page.77-  await page.goto(`chrome-extension://${extId}/src/popup/index.html?codex=restore`);83+  await page.goto(`chrome-extension://${extId}/src/popup/index.html?${search}`);7884 79-  // The file input lives inside <Show when={phase() === "choose"}> which is80-  // true at mount. Wait for it to be attached before handing back the page.85+  // The hidden file input is mounted alongside the restore view. Wait for it86+  // to be attached before handing back the page.8187   await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 });8288 8389   return page;@@ -162,6 +168,39 @@162168     console.log("Body snippet:", bodyText.slice(0, 300));163169 164170     expect(bodyText).not.toContain("no Codex codes found");171+    expect(bodyText).not.toContain("failed to read");172+  });173+174+  test("imports the current-generation Codex PDF when the fixture is present", async () => {175+    test.setTimeout(180_000);176+    if (!existsSync(CURRENT_GEN_PDF_PATH)) {177+      test.skip();178+      return;179+    }180+181+    const extId = await getExtId(ctx);182+    const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1");183+    await page.locator('input[type="file"]').setInputFiles(CURRENT_GEN_PDF_PATH);184+185+    await page.waitForFunction(186+      () => {187+        const body = document.body.textContent ?? "";188+        return (189+          body.includes("master password") ||190+          body.includes("pdf import needs another pass") ||191+          body.includes("failed to read")192+        );193+      },194+      { timeout: 150_000, polling: 1_000 },195+    );196+197+    const metrics = await page.evaluate(() => (window as any).__codexImportMetrics ?? null);198+    expect(metrics).not.toBeNull();199+    expect(metrics.decodeRate).toBeGreaterThanOrEqual(0.98);200+    expect(metrics.shortStripeCount).toBe(0);201+    expect(metrics.perStripeDecoded.length).toBeGreaterThan(0);202+203+    const bodyText = await page.evaluate(() => document.body.textContent ?? "");165204     expect(bodyText).not.toContain("failed to read");166205   });167206 });

view file