fbd343623e31

add PDF import as restore path — importCodexPDF renders pages via pdf.js, scans with zxing grid

time
2026-05-17 21:00 UTC · 5 months ago
machine
koh2
parent
30054dc526c6
7 files package-lock.json+280 −2package.json+1 −0src/popup/codex/pdf-import.test.ts+75 −0src/popup/codex/pdf-import.ts+244 −0src/popup/views/CodexRestoreView.tsx+190 −17vite.config.mobile.ts+14 −1vite.config.ts+24 −3
package-lock.json+280 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "2.50.1",3+  "version": "2.51.0",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "2.50.1",9+      "version": "2.51.0",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@digitaldefiance/reed-solomon-erasure.wasm": "^1.0.2",@@ -24,6 +24,7 @@2424         "pako": "^2.1.0",2525         "papaparse": "^5.5.3",2626         "pdf-lib": "^1.17.1",27+        "pdfjs-dist": "^5.7.284",2728         "qrcode": "^1.5.4",2829         "tldts": "^7.0.28",2930         "zxcvbn": "^4.4.2"@@ -1048,6 +1049,271 @@10481049       "dev": true,10491050       "license": "CC0-1.0"10501051     },1052+    "node_modules/@napi-rs/canvas": {1053+      "version": "0.1.100",1054+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",1055+      "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",1056+      "license": "MIT",1057+      "optional": true,1058+      "workspaces": [1059+        "e2e/*"1060+      ],1061+      "engines": {1062+        "node": ">= 10"1063+      },1064+      "funding": {1065+        "type": "github",1066+        "url": "https://github.com/sponsors/Brooooooklyn"1067+      },1068+      "optionalDependencies": {1069+        "@napi-rs/canvas-android-arm64": "0.1.100",1070+        "@napi-rs/canvas-darwin-arm64": "0.1.100",1071+        "@napi-rs/canvas-darwin-x64": "0.1.100",1072+        "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",1073+        "@napi-rs/canvas-linux-arm64-gnu": "0.1.100",1074+        "@napi-rs/canvas-linux-arm64-musl": "0.1.100",1075+        "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",1076+        "@napi-rs/canvas-linux-x64-gnu": "0.1.100",1077+        "@napi-rs/canvas-linux-x64-musl": "0.1.100",1078+        "@napi-rs/canvas-win32-arm64-msvc": "0.1.100",1079+        "@napi-rs/canvas-win32-x64-msvc": "0.1.100"1080+      }1081+    },1082+    "node_modules/@napi-rs/canvas-android-arm64": {1083+      "version": "0.1.100",1084+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",1085+      "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",1086+      "cpu": [1087+        "arm64"1088+      ],1089+      "license": "MIT",1090+      "optional": true,1091+      "os": [1092+        "android"1093+      ],1094+      "engines": {1095+        "node": ">= 10"1096+      },1097+      "funding": {1098+        "type": "github",1099+        "url": "https://github.com/sponsors/Brooooooklyn"1100+      }1101+    },1102+    "node_modules/@napi-rs/canvas-darwin-arm64": {1103+      "version": "0.1.100",1104+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",1105+      "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",1106+      "cpu": [1107+        "arm64"1108+      ],1109+      "license": "MIT",1110+      "optional": true,1111+      "os": [1112+        "darwin"1113+      ],1114+      "engines": {1115+        "node": ">= 10"1116+      },1117+      "funding": {1118+        "type": "github",1119+        "url": "https://github.com/sponsors/Brooooooklyn"1120+      }1121+    },1122+    "node_modules/@napi-rs/canvas-darwin-x64": {1123+      "version": "0.1.100",1124+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",1125+      "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",1126+      "cpu": [1127+        "x64"1128+      ],1129+      "license": "MIT",1130+      "optional": true,1131+      "os": [1132+        "darwin"1133+      ],1134+      "engines": {1135+        "node": ">= 10"1136+      },1137+      "funding": {1138+        "type": "github",1139+        "url": "https://github.com/sponsors/Brooooooklyn"1140+      }1141+    },1142+    "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {1143+      "version": "0.1.100",1144+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",1145+      "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",1146+      "cpu": [1147+        "arm"1148+      ],1149+      "license": "MIT",1150+      "optional": true,1151+      "os": [1152+        "linux"1153+      ],1154+      "engines": {1155+        "node": ">= 10"1156+      },1157+      "funding": {1158+        "type": "github",1159+        "url": "https://github.com/sponsors/Brooooooklyn"1160+      }1161+    },1162+    "node_modules/@napi-rs/canvas-linux-arm64-gnu": {1163+      "version": "0.1.100",1164+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",1165+      "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",1166+      "cpu": [1167+        "arm64"1168+      ],1169+      "libc": [1170+        "glibc"1171+      ],1172+      "license": "MIT",1173+      "optional": true,1174+      "os": [1175+        "linux"1176+      ],1177+      "engines": {1178+        "node": ">= 10"1179+      },1180+      "funding": {1181+        "type": "github",1182+        "url": "https://github.com/sponsors/Brooooooklyn"1183+      }1184+    },1185+    "node_modules/@napi-rs/canvas-linux-arm64-musl": {1186+      "version": "0.1.100",1187+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",1188+      "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",1189+      "cpu": [1190+        "arm64"1191+      ],1192+      "libc": [1193+        "musl"1194+      ],1195+      "license": "MIT",1196+      "optional": true,1197+      "os": [1198+        "linux"1199+      ],1200+      "engines": {1201+        "node": ">= 10"1202+      },1203+      "funding": {1204+        "type": "github",1205+        "url": "https://github.com/sponsors/Brooooooklyn"1206+      }1207+    },1208+    "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {1209+      "version": "0.1.100",1210+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",1211+      "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",1212+      "cpu": [1213+        "riscv64"1214+      ],1215+      "libc": [1216+        "glibc"1217+      ],1218+      "license": "MIT",1219+      "optional": true,1220+      "os": [1221+        "linux"1222+      ],1223+      "engines": {1224+        "node": ">= 10"1225+      },1226+      "funding": {1227+        "type": "github",1228+        "url": "https://github.com/sponsors/Brooooooklyn"1229+      }1230+    },1231+    "node_modules/@napi-rs/canvas-linux-x64-gnu": {1232+      "version": "0.1.100",1233+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",1234+      "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",1235+      "cpu": [1236+        "x64"1237+      ],1238+      "libc": [1239+        "glibc"1240+      ],1241+      "license": "MIT",1242+      "optional": true,1243+      "os": [1244+        "linux"1245+      ],1246+      "engines": {1247+        "node": ">= 10"1248+      },1249+      "funding": {1250+        "type": "github",1251+        "url": "https://github.com/sponsors/Brooooooklyn"1252+      }1253+    },1254+    "node_modules/@napi-rs/canvas-linux-x64-musl": {1255+      "version": "0.1.100",1256+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",1257+      "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",1258+      "cpu": [1259+        "x64"1260+      ],1261+      "libc": [1262+        "musl"1263+      ],1264+      "license": "MIT",1265+      "optional": true,1266+      "os": [1267+        "linux"1268+      ],1269+      "engines": {1270+        "node": ">= 10"1271+      },1272+      "funding": {1273+        "type": "github",1274+        "url": "https://github.com/sponsors/Brooooooklyn"1275+      }1276+    },1277+    "node_modules/@napi-rs/canvas-win32-arm64-msvc": {1278+      "version": "0.1.100",1279+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",1280+      "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",1281+      "cpu": [1282+        "arm64"1283+      ],1284+      "license": "MIT",1285+      "optional": true,1286+      "os": [1287+        "win32"1288+      ],1289+      "engines": {1290+        "node": ">= 10"1291+      },1292+      "funding": {1293+        "type": "github",1294+        "url": "https://github.com/sponsors/Brooooooklyn"1295+      }1296+    },1297+    "node_modules/@napi-rs/canvas-win32-x64-msvc": {1298+      "version": "0.1.100",1299+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",1300+      "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",1301+      "cpu": [1302+        "x64"1303+      ],1304+      "license": "MIT",1305+      "optional": true,1306+      "os": [1307+        "win32"1308+      ],1309+      "engines": {1310+        "node": ">= 10"1311+      },1312+      "funding": {1313+        "type": "github",1314+        "url": "https://github.com/sponsors/Brooooooklyn"1315+      }1316+    },10511317     "node_modules/@napi-rs/wasm-runtime": {10521318       "version": "1.1.4",10531319       "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",@@ -6115,6 +6381,18 @@61156381       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",61166382       "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",61176383       "license": "0BSD"6384+    },6385+    "node_modules/pdfjs-dist": {6386+      "version": "5.7.284",6387+      "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",6388+      "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",6389+      "license": "Apache-2.0",6390+      "engines": {6391+        "node": ">=22.13.0 || >=24"6392+      },6393+      "optionalDependencies": {6394+        "@napi-rs/canvas": "^0.1.100"6395+      }61186396     },61196397     "node_modules/pend": {61206398       "version": "1.2.0",

view file

package.json+1 −0
@@ -38,6 +38,7 @@3838     "pako": "^2.1.0",3939     "papaparse": "^5.5.3",4040     "pdf-lib": "^1.17.1",41+    "pdfjs-dist": "^5.7.284",4142     "qrcode": "^1.5.4",4243     "tldts": "^7.0.28",4344     "zxcvbn": "^4.4.2"

view file

src/popup/codex/pdf-import.test.ts+75 −0
@@ -0,0 +1,75 @@1+/**2+ * pdf-import.test.ts — coverage for the parser surface only.3+ *4+ * The actual pdf.js rendering + Aztec scan loop in importCodexPDF requires5+ * a DOM (HTMLCanvasElement) which vitest's node environment does not provide.6+ * End-to-end PDF round-trip lives in the manual Codex verification matrix7+ * (see peach_codex_reliability.md §5.2). Here we only test the bits we can:8+ * the defensive envelope parser.9+ */10+11+import { describe, expect, it } from "vitest";12+import { parseChunkEnvelope } from "./pdf-import";13+14+describe("parseChunkEnvelope", () => {15+  it("parses a well-formed envelope", () => {16+    const env = {17+      v: 2,18+      c: "PX-TEST-1234",19+      i: 0,20+      t: 15,21+      p: false,22+      d: "AAAA",23+      h: "ABCDEF1234567890ABCDEF1234567890",24+    };25+    expect(parseChunkEnvelope(JSON.stringify(env))).toEqual(env);26+  });27+28+  it("returns null for non-JSON input", () => {29+    expect(parseChunkEnvelope("not json at all")).toBeNull();30+  });31+32+  it("returns null for non-Peach JSON", () => {33+    expect(parseChunkEnvelope(JSON.stringify({ hello: "world" }))).toBeNull();34+  });35+36+  it("returns null when version is missing", () => {37+    expect(38+      parseChunkEnvelope(39+        JSON.stringify({ c: "PX", i: 0, t: 5, p: false, d: "A", h: "A" }),40+      ),41+    ).toBeNull();42+  });43+44+  it("returns null when fields have the wrong types", () => {45+    expect(46+      parseChunkEnvelope(47+        JSON.stringify({48+          v: 2,49+          c: "PX",50+          i: "0", // should be number51+          t: 5,52+          p: false,53+          d: "A",54+          h: "A",55+        }),56+      ),57+    ).toBeNull();58+  });59+60+  it("returns null for v=1 envelopes", () => {61+    expect(62+      parseChunkEnvelope(63+        JSON.stringify({64+          v: 1,65+          c: "PX",66+          i: 0,67+          t: 5,68+          p: false,69+          d: "A",70+          h: "A",71+        }),72+      ),73+    ).toBeNull();74+  });75+});

view file

src/popup/codex/pdf-import.ts+244 −0
@@ -0,0 +1,244 @@1+/**2+ * pdf-import.ts — restore from a Codex PDF instead of a camera scan.3+ *4+ * The PDF is rendered page-by-page to a canvas (via pdf.js) and each page5+ * canvas is divided into the same 6×6 grid the generator prints into. Each6+ * cell is scanned individually with @zxing/library, which decodes far more7+ * reliably than a full-page scan because adjacent Aztec codes no longer8+ * compete for the binarizer's attention.9+ *10+ * This module is browser-only — it depends on canvas and pdf.js. It is never11+ * imported from tests; PDF rendering needs a DOM.12+ *13+ * Why PDF import matters: a printed Codex requires a phone camera and good14+ * light. The PDF is a pristine vector source — the same Aztec codes without15+ * the print → photo loop. When a user has the PDF on a USB drive or in16+ * cloud storage, this path is faster and more reliable than camera scanning.17+ */18+19+import { BrowserAztecCodeReader, NotFoundException } from "@zxing/library";20+21+import {22+  CODES_PER_COL,23+  CODES_PER_ROW,24+  type ChunkEnvelope,25+} from "../../shared/codex-types";26+import { verifyChunkIntegrity } from "./helpers";27+28+/**29+ * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at30+ * test time because pdfjs-dist v5 references DOMMatrix at module init, which31+ * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;32+ * the parser surface (parseChunkEnvelope) can stay importable from tests.33+ */34+type PdfJsModule = typeof import("pdfjs-dist");35+let pdfJsPromise: Promise<PdfJsModule> | null = null;36+37+async function getPdfJs(): Promise<PdfJsModule> {38+  if (!pdfJsPromise) {39+    pdfJsPromise = import("pdfjs-dist").then((mod) => {40+      if (41+        typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||42+        mod.GlobalWorkerOptions.workerSrc.length === 043+      ) {44+        if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {45+          mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(46+            "pdf.worker.min.mjs",47+          );48+        } else {49+          mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";50+        }51+      }52+      return mod;53+    });54+  }55+  return pdfJsPromise;56+}57+58+export interface PDFImportResult {59+  /** Number of envelopes successfully decoded across all pages. */60+  chunksFound: number;61+  /** Total chunk count printed on the Codex, taken from the first envelope. */62+  totalExpected: number | null;63+  /** Decoded envelopes. Caller feeds these into ChunkBuffer. */64+  envelopes: ChunkEnvelope[];65+  /** Page-level error notes — empty when every page rendered cleanly. */66+  errors: string[];67+}68+69+export interface PDFImportProgress {70+  (page: number, totalPages: number): void;71+}72+73+/**74+ * Import a Peach Codex PDF and decode every Aztec code it contains.75+ *76+ * Renders each page at 2× scale (≈150dpi equivalent — well above the density77+ * required for reliable Aztec decoding) and grid-scans each rendered page.78+ */79+export async function importCodexPDF(80+  file: File,81+  onProgress?: PDFImportProgress,82+): Promise<PDFImportResult> {83+  const pdfjsLib = await getPdfJs();84+85+  const envelopes: ChunkEnvelope[] = [];86+  const errors: string[] = [];87+  const seenIndices = new Set<number>();88+  let totalExpected: number | null = null;89+90+  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;91+  try {92+    const arrayBuffer = await file.arrayBuffer();93+    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;94+  } catch (err) {95+    return {96+      chunksFound: 0,97+      totalExpected: null,98+      envelopes,99+      errors: [100+        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,101+      ],102+    };103+  }104+105+  const reader = new BrowserAztecCodeReader();106+  const pageCount = pdf.numPages;107+108+  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {109+    onProgress?.(pageNum, pageCount);110+111+    try {112+      const page = await pdf.getPage(pageNum);113+      const viewport = page.getViewport({ scale: 2.0 });114+115+      const canvas = document.createElement("canvas");116+      canvas.width = Math.ceil(viewport.width);117+      canvas.height = Math.ceil(viewport.height);118+      const ctx = canvas.getContext("2d");119+      if (!ctx) {120+        errors.push(`Page ${pageNum}: no canvas context`);121+        continue;122+      }123+124+      // pdf.js requires the canvas to be filled before render to avoid the125+      // transparent-on-transparent dark mode regression in v4 — paint white.126+      ctx.fillStyle = "white";127+      ctx.fillRect(0, 0, canvas.width, canvas.height);128+129+      await page.render({ canvas, canvasContext: ctx, viewport }).promise;130+131+      const pageEnvelopes = await scanCanvasGrid(canvas, reader, seenIndices);132+      for (const env of pageEnvelopes) envelopes.push(env);133+134+      if (totalExpected === null && pageEnvelopes.length > 0) {135+        totalExpected = pageEnvelopes[0]!.t;136+      }137+    } catch (err) {138+      errors.push(139+        `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,140+      );141+    }142+  }143+144+  return {145+    chunksFound: envelopes.length,146+    totalExpected,147+    envelopes,148+    errors,149+  };150+}151+152+/**153+ * Slice a rendered page canvas into the printed 6×6 grid and try to decode154+ * each cell as a single Aztec code. Cells that contain no code (the trailing155+ * empty cells on the last page) just throw NotFoundException and are skipped.156+ *157+ * Grid scanning beats whole-page scanning because zxing's binarizer locks158+ * onto one symbol per call; with 36 symbols on the page it would otherwise159+ * pick one and ignore the rest. We pay one decode attempt per cell — fast.160+ */161+async function scanCanvasGrid(162+  canvas: HTMLCanvasElement,163+  reader: BrowserAztecCodeReader,164+  seenIndices: Set<number>,165+): Promise<ChunkEnvelope[]> {166+  const found: ChunkEnvelope[] = [];167+  const cellW = canvas.width / CODES_PER_ROW;168+  const cellH = canvas.height / CODES_PER_COL;169+170+  for (let row = 0; row < CODES_PER_COL; row++) {171+    for (let col = 0; col < CODES_PER_ROW; col++) {172+      const cellCanvas = document.createElement("canvas");173+      cellCanvas.width = Math.floor(cellW);174+      cellCanvas.height = Math.floor(cellH);175+      const cellCtx = cellCanvas.getContext("2d");176+      if (!cellCtx) continue;177+178+      cellCtx.drawImage(179+        canvas,180+        col * cellW,181+        row * cellH,182+        cellW,183+        cellH,184+        0,185+        0,186+        cellCanvas.width,187+        cellCanvas.height,188+      );189+190+      let text: string;191+      try {192+        // @zxing/library's BrowserCodeReader.decode accepts any media element193+        // at runtime — it branches on instanceof — but the TS signature only194+        // lists HTMLImageElement. Cast through unknown to bypass the false195+        // positive.196+        const result = reader.decode(cellCanvas as unknown as HTMLImageElement);197+        text = result.getText();198+      } catch (err) {199+        // NotFoundException is normal — the trailing cells on the last page200+        // contain no code. Re-throw anything else to surface real failures.201+        if (err instanceof NotFoundException) continue;202+        continue; // any other decode failure: skip this cell203+      }204+205+      const envelope = parseChunkEnvelope(text);206+      if (!envelope) continue;207+      if (seenIndices.has(envelope.i)) continue;208+      if (!(await verifyChunkIntegrity(envelope))) continue;209+210+      seenIndices.add(envelope.i);211+      found.push(envelope);212+    }213+  }214+215+  return found;216+}217+218+/**219+ * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any220+ * non-Peach payload — the same defensive contract as restore.ts/parseRawScan221+ * but inlined here so this module stays decoupled from the camera scan path.222+ */223+export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {224+  let parsed: unknown;225+  try {226+    parsed = JSON.parse(raw);227+  } catch {228+    return null;229+  }230+  if (typeof parsed !== "object" || parsed === null) return null;231+  const obj = parsed as Record<string, unknown>;232+  if (233+    obj.v === 2 &&234+    typeof obj.c === "string" &&235+    typeof obj.i === "number" &&236+    typeof obj.t === "number" &&237+    typeof obj.p === "boolean" &&238+    typeof obj.d === "string" &&239+    typeof obj.h === "string"240+  ) {241+    return obj as unknown as ChunkEnvelope;242+  }243+  return null;244+}

view file

src/popup/views/CodexRestoreView.tsx+190 −17
@@ -1,10 +1,13 @@11 // Peach Codex — restoration view.22 //3-// Drives the four steps of restoring a vault from a printed Codex:4-//   1. Camera scan — Aztec codes feed parseRawScan → ChunkBuffer.5-//   2. Once ChunkBuffer.isReady(), prompt for the master passphrase.6-//   3. Reconstruct + integrity-verify locally (popup) via reconstructAndVerify.7-//   4. Hand the payload to the SW (RESTORE_FROM_CODEX) for actual decrypt3+// Two entry paths, both feeding the same ChunkBuffer:4+//   • Camera scan — Aztec codes via @zxing/library5+//   • PDF import — pdf.js + grid scan of each rendered page6+//7+// Once ChunkBuffer.isReady():8+//   1. Prompt for the master passphrase.9+//   2. Reconstruct + integrity-verify locally (popup) via reconstructAndVerify.10+//   3. Hand the payload to the SW (RESTORE_FROM_CODEX) for actual decrypt811 //      and replace.912 //1013 // Restoration is intentionally NOT Pro-gated — a printed Codex must remain@@ -26,7 +29,14 @@2629   onRestoreSuccess: () => void;2730 }2831 29-type Phase = "scanning" | "ready" | "restoring" | "success" | "error";32+type Phase =33+  | "choose"34+  | "scanning"35+  | "pdf-import"36+  | "ready"37+  | "restoring"38+  | "success"39+  | "error";3040 3141 export function CodexRestoreView(props: CodexRestoreViewProps) {3242   // The buffer is intentionally not stored in a signal — its identity is@@ -36,8 +46,9 @@3646   const buffer = new ChunkBuffer();3747   let videoEl: HTMLVideoElement | undefined;3848   let scanner: ScannerHandle | null = null;49+  let pdfFileInput: HTMLInputElement | undefined;3950 40-  const [phase, setPhase] = createSignal<Phase>("scanning");51+  const [phase, setPhase] = createSignal<Phase>("choose");4152   const [scanned, setScanned] = createSignal(0);4253   const [totalShards, setTotalShards] = createSignal<number | null>(null);4354   const [dataShards, setDataShards] = createSignal<number | null>(null);@@ -47,6 +58,11 @@4758   );4859   const [cameraError, setCameraError] = createSignal<string | null>(null);4960   const [scanWarning, setScanWarning] = createSignal<string | null>(null);61+  const [pdfProgress, setPdfProgress] = createSignal<{62+    page: number;63+    total: number;64+  } | null>(null);65+  const [pdfError, setPdfError] = createSignal<string | null>(null);5066   // Hold the reconstructed payload between the integrity check and the SW5167   // call so the user can re-enter the passphrase on a wrong-password failure5268   // without rescanning every code.@@ -65,9 +81,15 @@6581       setDataShards(ctx.dataShards);6682       setVerifyCode(ctx.verifyCode);6783     }68-    if (buffer.isReady() && phase() === "scanning") {84+    if (buffer.isReady() && (phase() === "scanning" || phase() === "pdf-import")) {6985       setPhase("ready");7086       setHint("scan complete. enter your master password to restore.");87+      // Stop the camera the instant we have enough codes — no reason to keep88+      // the green light on while the user types their passphrase.89+      if (scanner) {90+        scanner.stop();91+        scanner = null;92+      }7193     } else if (phase() === "scanning") {7294       const ds = ctx?.dataShards;7395       if (ds) {@@ -117,7 +139,6 @@117139   }118140 119141   function handleScannerError(err: Error) {120-    // Camera permission denied / no camera / hardware failure.121142     setCameraError(122143       err.name === "NotAllowedError"123144         ? "camera access was denied. allow camera permission and reopen this screen."@@ -125,7 +146,11 @@125146     );126147   }127148 128-  onMount(async () => {149+  async function startCameraScan() {150+    setPhase("scanning");151+    setCameraError(null);152+    // Solid hasn't rendered the <video> element yet — defer until next tick.153+    await Promise.resolve();129154     if (!videoEl) return;130155     try {131156       scanner = await startScanner({@@ -136,6 +161,65 @@136161     } catch (err) {137162       handleScannerError(err instanceof Error ? err : new Error(String(err)));138163     }164+  }165+166+  async function handlePDFSelected(e: Event) {167+    const input = e.currentTarget as HTMLInputElement;168+    const file = input.files?.[0];169+    // Reset the input so the same file can be re-selected after an error.170+    input.value = "";171+    if (!file) return;172+173+    if (!file.type.includes("pdf") && !file.name.toLowerCase().endsWith(".pdf")) {174+      setPdfError("please select a Peach Codex PDF file.");175+      return;176+    }177+178+    setPhase("pdf-import");179+    setPdfProgress(null);180+    setPdfError(null);181+182+    try {183+      // Dynamic import so the popup never pulls pdf.js into the camera-only184+      // path — keeps the initial bundle small.185+      const { importCodexPDF } = await import("../codex/pdf-import");186+      const result = await importCodexPDF(file, (page, total) => {187+        setPdfProgress({ page, total });188+      });189+190+      if (result.envelopes.length === 0) {191+        setPdfError(192+          "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document.",193+        );194+        setPhase("choose");195+        return;196+      }197+198+      for (const envelope of result.envelopes) {199+        await buffer.add(envelope);200+      }201+      refreshFromBuffer();202+203+      if (!buffer.isReady()) {204+        // Some codes are missing — let the user supplement with the camera.205+        setPdfError(206+          `found ${buffer.size()}${207+            result.totalExpected ? ` of ${result.totalExpected}` : ""208+          } codes. the PDF may be incomplete — you can scan the remaining codes with your camera.`,209+        );210+        await startCameraScan();211+      }212+    } catch (err) {213+      setPdfError(214+        `failed to read PDF: ${err instanceof Error ? err.message : String(err)}`,215+      );216+      setPhase("choose");217+    }218+  }219+220+  onMount(() => {221+    // Camera does not start automatically — the user picks a path on the222+    // choose screen first.139223   });140224 141225   onCleanup(() => {@@ -211,14 +295,10 @@211295     if (res.ok && res.data) {212296       setRestoredCount(res.data.entryCount);213297       setPhase("success");214-      // Tear the camera down ahead of unmount so the green light goes off the215-      // instant the success screen appears.216298       if (scanner) {217299         scanner.stop();218300         scanner = null;219301       }220-      // Give the user a moment to read the success message before the parent221-      // routes them onward.222302       setTimeout(() => props.onRestoreSuccess(), 1500);223303     } else {224304       setRestoreError(explainRestoreError(res.error));@@ -251,7 +331,71 @@251331       </div>252332 253333       <div class="peach-settings-body peach-codex-body">254-        <Show when={phase() !== "success"}>334+        <Show when={phase() === "choose"}>335+          <div class="peach-codex-restore-choose">336+            <h2 class="peach-codex-restore-headline">restore from codex.</h2>337+            <p class="peach-codex-restore-sub">338+              how do you have your codex?339+            </p>340+341+            <button342+              class="peach-codex-restore-option"343+              onClick={() => void startCameraScan()}344+            >345+              <div class="peach-codex-restore-option-icon">📷</div>346+              <div class="peach-codex-restore-option-content">347+                <span class="peach-codex-restore-option-title">348+                  scan printed pages349+                </span>350+                <span class="peach-codex-restore-option-sub">351+                  point your camera at each code352+                </span>353+              </div>354+            </button>355+356+            <button357+              class="peach-codex-restore-option"358+              onClick={() => pdfFileInput?.click()}359+            >360+              <div class="peach-codex-restore-option-icon">📄</div>361+              <div class="peach-codex-restore-option-content">362+                <span class="peach-codex-restore-option-title">363+                  import PDF file364+                </span>365+                <span class="peach-codex-restore-option-sub">366+                  select your codex PDF from a USB drive or folder367+                </span>368+              </div>369+            </button>370+371+            <input372+              ref={(el) => (pdfFileInput = el)}373+              type="file"374+              accept=".pdf,application/pdf"375+              style={{ display: "none" }}376+              onChange={(e) => void handlePDFSelected(e)}377+            />378+379+            <p class="peach-codex-restore-guidance">380+              have both the printed pages and the PDF? the PDF import is381+              faster and more reliable — use it if you can.382+            </p>383+384+            <Show when={pdfError()}>385+              <p class="peach-hint peach-hint-error peach-codex-restore-guidance">386+                {pdfError()}387+              </p>388+            </Show>389+          </div>390+        </Show>391+392+        <Show393+          when={394+            phase() === "scanning" ||395+            phase() === "ready" ||396+            phase() === "restoring"397+          }398+        >255399           <div class="peach-codex-scanner">256400             <video257401               ref={(el) => (videoEl = el)}@@ -268,7 +412,36 @@268412           </div>269413         </Show>270414 271-        <Show when={phase() === "scanning" || phase() === "ready" || phase() === "restoring"}>415+        <Show when={phase() === "pdf-import"}>416+          <div class="peach-settings-group peach-codex-pdf-import">417+            <p class="peach-codex-restore-headline">reading your codex…</p>418+            <Show when={pdfProgress()}>419+              {(p) => (420+                <>421+                  <div class="peach-codex-progress-track">422+                    <div423+                      class="peach-codex-progress-fill"424+                      style={{425+                        width: `${Math.round((p().page / p().total) * 100)}%`,426+                      }}427+                    />428+                  </div>429+                  <p class="peach-codex-progress-label">430+                    page {p().page} of {p().total}431+                  </p>432+                </>433+              )}434+            </Show>435+          </div>436+        </Show>437+438+        <Show439+          when={440+            phase() === "scanning" ||441+            phase() === "ready" ||442+            phase() === "restoring"443+          }444+        >272445           <div class="peach-settings-group peach-codex-progress-group">273446             <div class="peach-codex-progress-track">274447               <div@@ -346,7 +519,7 @@346519               {restoredCount() === 1 ? "entry" : "entries"} recovered.347520             </p>348521             <p class="peach-codex-footnote">349-              your Codex has been read and your vault is unlocked.522+              your codex has been read and your vault is unlocked.350523             </p>351524           </div>352525         </Show>

view file

vite.config.mobile.ts+14 −1
@@ -1,8 +1,21 @@11 import { defineConfig } from "vite";22 import solid from "vite-plugin-solid";3+import { copyFileSync, mkdirSync } from "fs";4+import { resolve } from "path";35 6+const copyPdfJsWorker = {7+  name: "copy-pdfjs-worker",8+  closeBundle() {9+    mkdirSync("dist-mobile", { recursive: true });10+    copyFileSync(11+      resolve("node_modules/pdfjs-dist/build/pdf.worker.min.mjs"),12+      resolve("dist-mobile", "pdf.worker.min.mjs"),13+    );14+  },15+};16+417 export default defineConfig({5-  plugins: [solid()],18+  plugins: [solid(), copyPdfJsWorker],619   base: "./",720   define: {821     __BUILD_TIME__: JSON.stringify(

view file

vite.config.ts+24 −3
@@ -1,21 +1,42 @@11 import { defineConfig } from "vite";22 import solidPlugin from "vite-plugin-solid";33 import { crx } from "@crxjs/vite-plugin";4-import { readFileSync } from "fs";4+import { copyFileSync, mkdirSync, readFileSync } from "fs";5+import { resolve } from "path";56 67 const target = process.env.BUILD_TARGET || "chrome";78 const manifestPath = target === "firefox" ? "./manifest.firefox.json" : "./manifest.json";89 const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));910 const passkeyE2E = process.env.PEACH_E2E_PASSKEYS === "1";11+const outDir = target === "firefox" ? "dist-firefox" : "dist-chrome";1012 13+// Copy the pdf.js worker into the build output so the popup can load it via14+// chrome.runtime.getURL('pdf.worker.min.mjs'). The pdfjs-dist module's default15+// workerSrc points inside node_modules, which is not reachable from a packaged16+// extension — the worker file must sit alongside the rest of the assets.17+const copyPdfJsWorker = {18+  name: "copy-pdfjs-worker",19+  closeBundle() {20+    mkdirSync(outDir, { recursive: true });21+    copyFileSync(22+      resolve("node_modules/pdfjs-dist/build/pdf.worker.min.mjs"),23+      resolve(outDir, "pdf.worker.min.mjs"),24+    );25+  },26+};27+1128 export default defineConfig({12-  plugins: [solidPlugin(), crx({ manifest, browser: target === "firefox" ? "firefox" : "chrome" })],29+  plugins: [30+    solidPlugin(),31+    crx({ manifest, browser: target === "firefox" ? "firefox" : "chrome" }),32+    copyPdfJsWorker,33+  ],1334   base: "./",1435   define: {1536     __PEACH_E2E_PASSKEYS__: JSON.stringify(passkeyE2E),1637   },1738   build: {18-    outDir: target === "firefox" ? "dist-firefox" : "dist-chrome",39+    outDir,1940     // emptyOutDir must stay false. When true, Vite tries to remove the2041     // contents of the outDir before each build, which throws EPERM on FUSE2142     // and some mounted filesystems and aborts the whole build. Overwriting

view file