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
↓ peach-browser-fbd34362.tar.gzatlas at this save
files changed7 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 −3package-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",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"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+});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+}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>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(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