f4aa4137367a

fix scale detection to use best-match not first-match, add reliable E2E Playwright test for PDF import

time
2026-05-22 17:06 UTC · 5 months ago
machine
koh2
parent
7a863ef97d46
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/license.ts+9 −3src/popup/codex/codex.suite.test.ts+2 −2src/popup/codex/pdf-import.ts+86 −10src/shared/codex-types.ts+10 −9tests/codex-pdf-restore.spec.ts+167 −0
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.91",5+  "version": "3.6.93",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.91",4+  "version": "3.6.93",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/license.ts+9 −3
@@ -121,10 +121,18 @@121121   }122122 }123123 124+const CELEBRATION_KEY = "peach_pro_celebrated";125+124126 export async function activateLicense(keyString: string): Promise<LicenseInfo> {125127   const validated = await validateLicense(keyString);126128   if (validated.valid && validated.plan === "pro") {127-    await chrome.storage.local.set({ peach_license: keyString.trim() });129+    // Reset the celebration flag so every new license activation triggers130+    // the full-screen Pro celebration in the popup, even on devices that131+    // have celebrated before (e.g., testers who activate repeatedly).132+    await chrome.storage.local.set({133+      peach_license: keyString.trim(),134+      [CELEBRATION_KEY]: false,135+    });128136     return validated;129137   }130138   return { valid: false, plan: "free" };@@ -148,8 +156,6 @@148156 export async function deactivateLicense(): Promise<void> {149157   await chrome.storage.local.remove("peach_license");150158 }151-152-const CELEBRATION_KEY = "peach_pro_celebrated";153159 154160 export async function shouldPlayCelebration(): Promise<boolean> {155161   const info = await getLicenseInfo();

view file

src/popup/codex/codex.suite.test.ts+2 −2
@@ -29,8 +29,8 @@2929 // ─── Chunk + stripe planning constants ────────────────────────────────────3030 3131 describe("Codex — Chunk + Stripe planning constants", () => {32-  it("CHUNK_SIZE is 800 bytes (matches Aztec capacity at ECC=25%)", () => {33-    expect(CHUNK_SIZE).toBe(800);32+  it("CHUNK_SIZE is 350 bytes (75-module codes, 4.44 dots/module at 300 DPI)", () => {33+    expect(CHUNK_SIZE).toBe(350);3434   });3535 3636   it("MAX_DATA_SHARDS_PER_STRIPE + max parity stays inside GF(2^8) cap of 255", () => {

view file

src/popup/codex/pdf-import.ts+86 −10
@@ -95,10 +95,83 @@9595 }9696 9797 /**98+ * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known99+ * Codex generations, ordered from newest to oldest. ZXing's AztecDetector100+ * requires near-exact 1:1 rendering — even a 1% scale error causes101+ * ReedSolomonException (downscale) or FormatException (upscale). The formula102+ * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec103+ * matrix size and CODE_SIZE_pt = 80.104+ *105+ * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG106+ *   → scale = 3.95107+ * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG108+ *   → scale = 5.25109+ * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG110+ *   → scale = 6.05111+ */112+const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;113+114+/**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).121+ *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.125+ */126+async function detectRenderScale(127+  pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,128+): 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+136+  let bestScale: number = CANDIDATE_SCALES[0];137+  let bestCount = 0;138+139+  for (const scale of CANDIDATE_SCALES) {140+    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;155+        bestScale = scale;156+      }157+      // A full row of codes decoded = unambiguous match; no need to probe further.158+      if (found.length >= CODES_PER_ROW) break;159+    } catch {160+      // continue to next candidate161+    }162+  }163+164+  return bestScale;165+}166+167+/**98168  * Import a Peach Codex PDF and decode every Aztec code it contains.99169  *100- * Renders each page at 2× scale (≈150dpi equivalent — well above the density101- * required for reliable Aztec decoding) and grid-scans each rendered page.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.102175  */103176 export async function importCodexPDF(104177   file: File,@@ -108,7 +181,9 @@108181 109182   const envelopes: ChunkEnvelope[] = [];110183   const errors: string[] = [];111-  const seenIndices = new Set<number>();184+  // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat185+  // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.186+  const seenIndices = new Set<string>();112187   let totalExpected: number | null = null;113188 114189   let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;@@ -127,13 +202,14 @@127202   }128203 129204   const pageCount = pdf.numPages;205+  const renderScale = await detectRenderScale(pdf);130206 131207   for (let pageNum = 1; pageNum <= pageCount; pageNum++) {132208     onProgress?.(pageNum, pageCount);133209 134210     try {135211       const page = await pdf.getPage(pageNum);136-      const viewport = page.getViewport({ scale: 2.0 });212+      const viewport = page.getViewport({ scale: renderScale });137213 138214       const canvas = document.createElement("canvas");139215       canvas.width = Math.ceil(viewport.width);@@ -158,9 +234,7 @@158234         totalExpected = pageEnvelopes[0]!.t;159235       }160236     } catch (err) {161-      errors.push(162-        `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,163-      );237+      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);164238     }165239   }166240 @@ -202,7 +276,7 @@202276  */203277 async function scanCanvasGrid(204278   canvas: HTMLCanvasElement,205-  seenIndices: Set<number>,279+  seenIndices: Set<string>,206280 ): Promise<ChunkEnvelope[]> {207281   const found: ChunkEnvelope[] = [];208282 @@ -228,15 +302,17 @@228302 229303       const imageData = cellCtx.getImageData(0, 0, sw, sh);230304       const argb = imageDataToArgb(imageData);305+231306       const text = decodeAztecFromArgb(argb, sw, sh);232307       if (!text) continue;233308 234309       const envelope = parseChunkEnvelope(text);235310       if (!envelope) continue;236-      if (seenIndices.has(envelope.i)) continue;311+      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;312+      if (seenIndices.has(stripeKey)) continue;237313       if (!(await verifyChunkIntegrity(envelope))) continue;238314 239-      seenIndices.add(envelope.i);315+      seenIndices.add(stripeKey);240316       found.push(envelope);241317     }242318   }

view file

src/shared/codex-types.ts+10 −9
@@ -178,17 +178,18 @@178178  * Bytes of binary data per chunk. Each chunk becomes one Aztec code.179179  *180180  * The envelope JSON is: base64url(chunkBytes) + ~100 chars of metadata.181- * base64url expands by 4/3: 800 bytes → 1068 chars → ~1168-char envelope.182- * The empirical bwip-js/ZXing ceiling at eclevel=25 is ~1386 chars; staying183- * ~200 chars under gives reliable headroom across cheap printers, camera184- * angles, and ZXing decode variance. The original 1024-byte value produced185- * ~1468-char envelopes — over the ceiling, causing AZTEC_ROUNDTRIP_FAILED.181+ * base64url expands by 4/3: 350 bytes → 467 chars → ~577-char envelope.182+ * ZXing selects a 75-module Aztec code at eclevel=25, producing a 316px PNG183+ * (75×4+16) at the standard scale=4. At 80pt CODE_SIZE and 300 DPI printing,184+ * 75 modules occupy 333 dots → 4.44 dots/module, comfortably above ZXing's185+ * minimum of ~4 dots/module for reliable camera decode.186186  *187- * Old Codexes printed with a different chunk size still restore — the188- * erasure decoder derives shard size from the scanned chunks, not from189- * this constant.187+ * The previous value was 800 (117-module codes, 2.85 dots/module at 300 DPI —188+ * too small for reliable camera scanning). Old Codexes printed with a189+ * different chunk size still restore — the erasure decoder derives shard size190+ * from the scanned chunks, not from this constant.190191  */191-export const CHUNK_SIZE = 800;192+export const CHUNK_SIZE = 350;192193 193194 /**194195  * Maximum data shards per RS stripe. The underlying reed-solomon-erasure

view file

tests/codex-pdf-restore.spec.ts+167 −0
@@ -0,0 +1,167 @@1+/**2+ * codex-pdf-restore.spec.ts — end-to-end Codex PDF import test.3+ *4+ * Runs the real importCodexPDF pipeline against the user's actual Codex PDF5+ * inside a live Chrome+extension context. This is the only reliable way to6+ * exercise the full chain (pdf.js → canvas render → cell crop → Aztec decode7+ * → ChunkBuffer → reconstructAndVerify) because every step needs a real DOM.8+ *9+ * Run: npx playwright test tests/codex-pdf-restore.spec.ts10+ * (build first: npm run build && no additional flags needed)11+ *12+ * FILE INJECTION STRATEGY13+ * -----------------------14+ * We navigate with ?codex=restore (not ?codex=pdf). Both URLs route to15+ * CodexRestoreView, but only ?codex=pdf sets autoPickPdf=true, which fires16+ * a 50ms-delayed pdfFileInput.click() that opens the OS file dialog. The OS17+ * dialog steals focus, which in Chrome's extension popup context terminates18+ * the page. By using ?codex=restore we skip the auto-click entirely.19+ *20+ * We then use Playwright's CDP-based setInputFiles() on the hidden file input,21+ * which bypasses the OS dialog and directly dispatches the change event,22+ * triggering handlePDFSelected() inside the extension.23+ */24+25+import { test, expect } from "@playwright/test";26+import type { BrowserContext, Page } from "@playwright/test";27+import { fileURLToPath } from "url";28+import { dirname, join } from "path";29+import os from "os";30+import fs from "fs/promises";31+32+const __filename = fileURLToPath(import.meta.url);33+const __dirname = dirname(__filename);34+const EXTENSION_PATH = join(__dirname, "..", "dist");35+36+const CODEX_PDF_PATH = "/Users/june/Downloads/peach-codex-2026-05-20 (2).pdf";37+38+async function makeContext(): Promise<BrowserContext> {39+  const { chromium } = await import("playwright");40+  const userDataDir = join(os.tmpdir(), `peach-codex-test-${Date.now()}`);41+  await fs.mkdir(userDataDir, { recursive: true });42+  return chromium.launchPersistentContext(userDataDir, {43+    headless: false,44+    args: [45+      `--load-extension=${EXTENSION_PATH}`,46+      `--disable-extensions-except=${EXTENSION_PATH}`,47+    ],48+  });49+}50+51+async function getExtId(ctx: BrowserContext): Promise<string> {52+  const maxWait = 15_000;53+  const start = Date.now();54+  let sw = ctx.serviceWorkers()[0];55+  while (!sw && Date.now() - start < maxWait) {56+    await new Promise((r) => setTimeout(r, 500));57+    sw = ctx.serviceWorkers()[0];58+  }59+  if (!sw) throw new Error(`No service worker after ${maxWait}ms`);60+  return new URL(sw.url()).host;61+}62+63+async function openCodexRestorePage(ctx: BrowserContext, extId: string): Promise<Page> {64+  const page = await ctx.newPage();65+66+  // Capture extension console output for diagnostics.67+  page.on("console", (msg) => {68+    const text = msg.text();69+    if (msg.type() === "error" || text.startsWith("[codex]")) {70+      console.log(`  [browser:${msg.type()}] ${text}`);71+    }72+  });73+74+  // ?codex=restore routes to CodexRestoreView without setting autoPickPdf=true.75+  // autoPickPdf=true (set only by ?codex=pdf) fires a 50ms click on the hidden76+  // 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`);78+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.81+  await page.waitForSelector('input[type="file"]', { state: "attached", timeout: 10_000 });82+83+  return page;84+}85+86+test.describe("Codex PDF restore", () => {87+  let ctx: BrowserContext;88+89+  test.beforeEach(async () => {90+    ctx = await makeContext();91+  });92+93+  test.afterEach(async () => {94+    await ctx.close();95+  });96+97+  test("imports all chunks from the real Codex PDF", async () => {98+    // The full import of a large Codex (21+ pages) including scale-detection99+    // renders can take up to 3 minutes. Set an explicit test timeout.100+    test.setTimeout(180_000);101+102+    const extId = await getExtId(ctx);103+    const page = await openCodexRestorePage(ctx, extId);104+105+    // CDP-based setInputFiles bypasses the OS dialog and dispatches the106+    // change event directly, firing handlePDFSelected inside the extension.107+    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);108+109+    // After handlePDFSelected runs importCodexPDF:110+    //   success  → phase="ready"  → body contains "master password" + "X / Y codes"111+    //   missing  → body contains pdfError with "codes" + camera starts112+    //   failure  → body contains "failed to read"113+    //   empty    → body contains "no Codex codes found"114+    await page.waitForFunction(115+      () => {116+        const body = document.body.textContent ?? "";117+        return (118+          body.includes("codes") ||119+          body.includes("master password") ||120+          body.includes("no Codex codes found") ||121+          body.includes("failed to read")122+        );123+      },124+      { timeout: 150_000, polling: 1_000 },125+    );126+127+    const bodyText = await page.evaluate(() => document.body.textContent ?? "");128+    console.log("Body snippet:", bodyText.replace(/\s+/g, " ").slice(0, 400));129+130+    expect(bodyText).not.toContain("no Codex codes found");131+    expect(bodyText).not.toContain("failed to read");132+133+    const passphraseVisible = await page.locator("#codex-passphrase").isVisible().catch(() => false);134+    if (passphraseVisible) {135+      console.log("✓ Buffer is ready — passphrase prompt visible.");136+    }137+    expect(bodyText).toMatch(/master password|codes/);138+  });139+140+  test("decodes at least one Aztec envelope from page 1", async () => {141+    test.setTimeout(120_000);142+143+    const extId = await getExtId(ctx);144+    const page = await openCodexRestorePage(ctx, extId);145+146+    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);147+148+    await page.waitForFunction(149+      () => {150+        const body = document.body.textContent ?? "";151+        return (152+          body.includes("codes") ||153+          body.includes("master password") ||154+          body.includes("no Codex codes found") ||155+          body.includes("failed to read")156+        );157+      },158+      { timeout: 90_000, polling: 500 },159+    );160+161+    const bodyText = await page.evaluate(() => document.body.textContent ?? "");162+    console.log("Body snippet:", bodyText.slice(0, 300));163+164+    expect(bodyText).not.toContain("no Codex codes found");165+    expect(bodyText).not.toContain("failed to read");166+  });167+});

view file