43458142142a
load sync and pairing through static imports: runtime import() throws in an MV3 service worker, and Vite's preload helper reported it as "window is not defined", so the whole sync surface failed silently (v4.1.10)
- time
- 2026-07-26 12:21 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing room hang
- parent
- 06089c871dd2
↓ peach-browser-43458142.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+4 −3scripts/check-sw-static-imports.mjs+92 −0src/background/messages.ts+31 −21src/background/vault.ts+9 −9manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.9",4+ "version": "4.1.10",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.9",4+ "version": "4.1.10",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+4 −3
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.1.9",3+ "version": "4.1.10",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",@@ -9,7 +9,7 @@99 "dev": "vite",1010 "build:noise-core": "node scripts/build-noise-core.mjs",1111 "build": "npm run build:chrome && npm run build:firefox && npm run build:mobile",12- "build:chrome": "node scripts/clean-chrome-dist.mjs && BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",12+ "build:chrome": "node scripts/clean-chrome-dist.mjs && BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs && npm run check:sw",1313 "build:firefox": "node scripts/clean-firefox-artifacts.mjs && BUILD_TARGET=firefox vite build",1414 "build:mobile": "vite build --config vite.config.mobile.ts",1515 "build:e2e": "PEACH_E2E_PASSKEYS=1 BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",@@ -23,7 +23,8 @@2323 "release": "bun run scripts/release.ts",2424 "release:chrome": "bun run scripts/release.ts --chrome-only",2525 "release:firefox": "bun run scripts/release-firefox.ts",26- "submit:firefox": "web-ext sign --source-dir dist-firefox --artifacts-dir artifacts --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET --channel=listed"26+ "submit:firefox": "web-ext sign --source-dir dist-firefox --artifacts-dir artifacts --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET --channel=listed",27+ "check:sw": "node scripts/check-sw-static-imports.mjs"2728 },2829 "engines": {2930 "node": ">=26.0.0",scripts/check-sw-static-imports.mjs+92 −0
@@ -0,0 +1,92 @@1+// Fails the build if any chunk reachable from the MV3 service worker still2+// contains a runtime `import()`.3+//4+// Chrome throws `TypeError: import() is disallowed on ServiceWorkerGlobalScope`5+// for dynamic import inside a service worker, and Vite's module-preload helper6+// reports that failure via `window.dispatchEvent` — which also does not exist7+// in a worker. The original error is therefore replaced by "window is not8+// defined" and surfaces nowhere useful. The whole sync and pairing surface was9+// dead this way, silently, because Rollup happened to split those modules into10+// their own chunks while inlining everything else.11+//12+// Whether a given `await import()` survives to runtime depends on Rollup's13+// chunking, so source review cannot catch this — only the built output can.14+15+import { readFileSync, existsSync, readdirSync } from "fs";16+import { join, dirname } from "path";17+18+// Chrome only. The Firefox build declares `background.scripts`, an event page19+// with a real `window` and `document`, where dynamic import() is legal.20+const DISTS = ["dist-chrome"];21+22+const STATIC_IMPORT = /(?:^|[;\s})])(?:import|export)\s*(?:[^`"'()]*?from\s*)?[`"']([^`"']+)[`"']/g;23+const DYNAMIC_IMPORT = /import\(\s*[`"']([^`"']+)[`"']\s*\)/g;24+25+function matches(source, re) {26+ return [...source.matchAll(re)].map((m) => m[1]);27+}28+29+function resolveFrom(fromFile, spec) {30+ if (!spec.startsWith(".")) return null;31+ return join(dirname(fromFile), spec);32+}33+34+/** The worker entry, via the loader crxjs emits. */35+function serviceWorkerEntry(dist) {36+ const loader = join(dist, "service-worker-loader.js");37+ if (existsSync(loader)) {38+ const [spec] = matches(readFileSync(loader, "utf8"), STATIC_IMPORT);39+ if (spec) return resolveFrom(loader, spec);40+ }41+ const manifest = JSON.parse(readFileSync(join(dist, "manifest.json"), "utf8"));42+ const direct = manifest.background?.service_worker;43+ return direct ? join(dist, direct) : null;44+}45+46+function walk(entry) {47+ const seen = new Set();48+ const violations = [];49+ const stack = [entry];50+ while (stack.length) {51+ const file = stack.pop();52+ if (seen.has(file) || !existsSync(file)) continue;53+ seen.add(file);54+ const source = readFileSync(file, "utf8");55+ for (const spec of matches(source, STATIC_IMPORT)) {56+ const next = resolveFrom(file, spec);57+ if (next) stack.push(next);58+ }59+ for (const spec of matches(source, DYNAMIC_IMPORT)) {60+ violations.push({ file, spec });61+ }62+ }63+ return { chunks: seen.size, violations };64+}65+66+let failed = false;67+for (const dist of DISTS) {68+ if (!existsSync(dist)) {69+ console.error(`FAIL: ${dist} not built — nothing to check`);70+ failed = true;71+ continue;72+ }73+ const entry = serviceWorkerEntry(dist);74+ if (!entry || !existsSync(entry)) {75+ console.error(`FAIL ${dist}: could not locate the service worker entry`);76+ failed = true;77+ continue;78+ }79+ const { chunks, violations } = walk(entry);80+ if (violations.length) {81+ failed = true;82+ console.error(`FAIL ${dist}: runtime import() in the service worker graph`);83+ for (const v of violations) console.error(` ${v.file} -> import("${v.spec}")`);84+ console.error(85+ " Fix: make these static top-level imports. Dynamic import() cannot run in a service worker.",86+ );87+ } else {88+ console.log(`ok ${dist}: ${chunks} worker chunks, no runtime import()`);89+ }90+}91+92+process.exit(failed ? 1 : 0);src/background/messages.ts+31 −21
@@ -77,6 +77,37 @@7777 revokeDeviceById as revokeDeviceByIdV3,7878 upsertPairedDevice as upsertPairedDeviceV3,7979 } from "./paired-devices";80+// Sync and pairing are imported statically, never with `await import()`.81+// `import()` throws `TypeError: import() is disallowed on82+// ServiceWorkerGlobalScope` at runtime in MV3, and Vite's preload helper83+// reports the failure through `window.dispatchEvent`, which does not exist in84+// a worker either — so the real reason was replaced by "window is not defined"85+// and the pairing UI just stopped. scripts/check-sw-static-imports.mjs fails86+// the build if a runtime import() reappears anywhere in the worker's graph.87+import { parsePairingCode } from "../shared/pairing-code";88+import {89+ isCodePairingInFlight,90+ resolveCodePairingConfirm,91+ startCodePairing,92+} from "./code-pairing-bg";93+import {94+ cancelRelayAdoptionSource,95+ confirmRelayAdoptionSource,96+ isRelayAdoptionSourceInFlight,97+ startRelayAdoptionSource,98+} from "./relay-adoption-source-bg";99+import {100+ getRelaySettings,101+ setLanOnly,102+ setRelayUrl,103+} from "./relay-settings";104+import {105+ getSyncState,106+ hydratePersistedSyncState,107+ setSyncRunner,108+ triggerSyncIfReachable,109+} from "./sync-trigger";110+import { productionSyncRunner } from "./sync-runner";80111 81112 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in82113 // every production build — verified in the bundled output). Mirrors the@@ -205,8 +236,6 @@205236 if (nativeSyncRunnerBootstrapped) return;206237 nativeSyncRunnerBootstrapped = true;207238 try {208- const { setSyncRunner, hydratePersistedSyncState } = await import("./sync-trigger");209- const { productionSyncRunner } = await import("./sync-runner");210239 setSyncRunner(productionSyncRunner);211240 await hydratePersistedSyncState();212241 } catch (err) {@@ -1442,13 +1471,9 @@14421471 case "PAIR_WITH_CODE": {14431472 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };14441473 const { code } = (msg.payload ?? {}) as { code?: string };1445- const { parsePairingCode } = await import("../shared/pairing-code");14461474 if (typeof code !== "string" || parsePairingCode(code) === null) {14471475 return { ok: false, error: "invalid_payload" };14481476 }1449- const { startCodePairing, isCodePairingInFlight } = await import(1450- "./code-pairing-bg"1451- );14521477 if (isCodePairingInFlight()) return { ok: false, error: "pairing_in_progress" };14531478 startCodePairing(code);14541479 return { ok: true };@@ -1456,17 +1481,12 @@14561481 14571482 case "CODE_PAIR_CONFIRM": {14581483 const { confirmed } = (msg.payload ?? {}) as { confirmed?: boolean };1459- const { resolveCodePairingConfirm } = await import("./code-pairing-bg");14601484 resolveCodePairingConfirm(confirmed === true);14611485 return { ok: true };14621486 }14631487 14641488 case "START_ADOPTION_SOURCE": {14651489 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1466- const {1467- isRelayAdoptionSourceInFlight,1468- startRelayAdoptionSource,1469- } = await import("./relay-adoption-source-bg");14701490 if (isRelayAdoptionSourceInFlight()) {14711491 return { ok: false, error: "adoption_source_in_progress" };14721492 }@@ -1485,9 +1505,6 @@14851505 password?: unknown;14861506 cancel?: unknown;14871507 };1488- const { confirmRelayAdoptionSource } = await import(1489- "./relay-adoption-source-bg"1490- );14911508 if (cancel === true) {14921509 return {14931510 ok: true,@@ -1504,9 +1521,6 @@15041521 }15051522 15061523 case "CANCEL_ADOPTION_SOURCE": {1507- const { cancelRelayAdoptionSource } = await import(1508- "./relay-adoption-source-bg"1509- );15101524 cancelRelayAdoptionSource();15111525 return { ok: true };15121526 }@@ -1518,18 +1532,15 @@15181532 };15191533 const reason = payload.reason ?? "foreground";15201534 1521- const { triggerSyncIfReachable } = await import("./sync-trigger");15221535 void triggerSyncIfReachable({ reason, force: payload.force === true });15231536 return { ok: true };15241537 }15251538 15261539 case "GET_SYNC_STATE": {1527- const { getSyncState } = await import("./sync-trigger");15281540 return { ok: true, data: getSyncState() };15291541 }15301542 15311543 case "GET_RELAY_SETTINGS": {1532- const { getRelaySettings } = await import("./relay-settings");15331544 return { ok: true, data: await getRelaySettings() };15341545 }15351546 @@ -1538,7 +1549,6 @@15381549 relayUrl?: string;15391550 lanOnly?: boolean;15401551 };1541- const { getRelaySettings, setLanOnly, setRelayUrl } = await import("./relay-settings");15421552 try {15431553 if (payload.relayUrl !== undefined) {15441554 if (typeof payload.relayUrl !== "string") {src/background/vault.ts+9 −9
@@ -14,6 +14,10 @@1414 import { clampSyncTimestamp } from "../shared/sync-limits";1515 import { computeVaultId } from "../shared/sync-identity";1616 import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";17+// Static, not `await import()`: runtime import() throws in an MV3 service18+// worker. Neither module imports vault.ts back, so there is no cycle to break.19+import { triggerSyncIfReachable } from "./sync-trigger";20+import { clearPairedDevices } from "./paired-devices";1721 import {1822 rootKeyToWords,1923 wordsToRootKeyMaterial,@@ -197,16 +201,13 @@197201 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};198202 }199203 200-// Fire-and-forget sync trigger after a committed vault write. Lazy201-// dynamic import avoids a circular dependency between vault.ts and202-// sync-trigger.ts (the trigger imports paired-devices, which only reads203-// storage, but the lazy load also gives us a clean opt-out path in204-// tests where sync-trigger has not been initialised).204+// Fire-and-forget sync trigger after a committed vault write. A failure here205+// must never fail the write that caused it.205206 function fireSyncTrigger(reason: "vault_change"): void {206- void import("./sync-trigger")207- .then((mod) => mod.triggerSyncIfReachable({ reason }))207+ void Promise.resolve()208+ .then(() => triggerSyncIfReachable({ reason }))208209 .catch(() => {209- /* sync trigger module not available — non-fatal */210+ /* no sync runner registered, or nothing reachable — non-fatal */210211 });211212 }212213 @@ -2950,7 +2951,6 @@29502951 rootKey = newRootKey;29512952 29522953 // 5. Old peers' identities derive from the dead root key — drop them all.2953- const { clearPairedDevices } = await import("./paired-devices");29542954 await clearPairedDevices();29552955 29562956 // 6. Force re-presentation of the NEW recovery words on next popup view.