8266c804b32e

before saving carried-over codex slot fix + native sync bootstrap + adding inline-popup search affordance

time
2026-05-27 15:54 UTC · 5 months ago
machine
koh2
parent
d450644cd03d
7 files package-lock.json+2 −2package.json+1 −1src/background/codex-handlers.test.ts+44 −0src/background/codex-handlers.ts+11 −2src/background/messages.ts+18 −0src/background/sync-trigger.ts+39 −18src/popup/App.tsx+11 −0
package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.95",3+  "version": "3.6.98",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.95",9+      "version": "3.6.98",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

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

view file

src/background/codex-handlers.test.ts+44 −0
@@ -127,4 +127,48 @@127127     // malformed blob — we never want to touch storage on garbage input.128128     expect(unlockFromCodex).not.toHaveBeenCalled();129129   });130+131+  it("handleRestoreFromCodex passes through stringified slot payloads without double-encoding", async () => {132+    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));133+    const unlockFromCodex = vi.fn(async () => ({ ok: true as const, entryCount: 1 }));134+    vi.doMock("./vault", () => ({135+      unlockFromCodex,136+      isUnlocked: () => false,137+      getEntries: () => [],138+    }));139+    vi.doMock("../shared/native", () => ({140+      isNativePlatform: () => false,141+      nativePlugin: () => null,142+    }));143+144+    const payload = {145+      vaultBlob: Buffer.from(146+        JSON.stringify({147+          a: "{\"version\":1,\"salt\":\"deadbeef\",\"nonce\":\"cafebabe\",\"ciphertext\":\"00\",\"tag\":\"11\"}",148+          b: null,149+        }),150+        "utf8",151+      ).toString("base64url"),152+      timestamp: 1,153+      verifyCode: "PX-AAAA-BBBB",154+      vaultFingerprint: "0123456789ABCDEF0123456789ABCDEF",155+      entryCount: 1,156+      masterPassphrase: "correct horse battery staple",157+    };158+159+    const { handleRestoreFromCodex } = await import("./codex-handlers");160+    const result = await handleRestoreFromCodex(payload);161+162+    expect(result).toEqual({163+      ok: true,164+      data: { entryCount: 1, verifyCode: "PX-AAAA-BBBB" },165+    });166+    expect(unlockFromCodex).toHaveBeenCalledWith({167+      slotA:168+        "{\"version\":1,\"salt\":\"deadbeef\",\"nonce\":\"cafebabe\",\"ciphertext\":\"00\",\"tag\":\"11\"}",169+      slotB: null,170+      timestamp: 1,171+      masterPassphrase: "correct horse battery staple",172+    });173+  });130174 });

view file

src/background/codex-handlers.ts+11 −2
@@ -235,8 +235,17 @@235235     if (!parsed || typeof parsed !== "object" || parsed.a == null) {236236       return { ok: false, error: "INVALID_VAULT_BLOB" };237237     }238-    slotAJson = JSON.stringify(parsed.a);239-    slotBJson = parsed.b == null ? null : JSON.stringify(parsed.b);238+    // The generator stores each slot as a JSON string payload, so restore239+    // must pass that string through unchanged. Re-stringifying here would240+    // wrap the already-encoded JSON in quotes and make unlockFromCodex241+    // reject the blob as unreadable.242+    slotAJson = typeof parsed.a === "string" ? parsed.a : JSON.stringify(parsed.a);243+    slotBJson =244+      parsed.b == null245+        ? null246+        : typeof parsed.b === "string"247+          ? parsed.b248+          : JSON.stringify(parsed.b);240249   } catch {241250     return { ok: false, error: "INVALID_VAULT_BLOB" };242251   }

view file

src/background/messages.ts+18 −0
@@ -63,6 +63,8 @@6363   upsertPairedDevice as upsertPairedDeviceV3,6464 } from "./paired-devices";6565 66+let nativeSyncRunnerBootstrapped = false;67+6668 function unhexBytes(hex: string): Uint8Array {6769   if (hex.length % 2 !== 0) throw new Error("invalid hex");6870   const out = new Uint8Array(hex.length / 2);@@ -157,7 +159,23 @@157159       }158160     });159161   }162+  if (isNativePlatform()) {163+    void bootstrapNativeSyncRunner();164+  }165+}160166 167+async function bootstrapNativeSyncRunner(): Promise<void> {168+  if (nativeSyncRunnerBootstrapped) return;169+  nativeSyncRunnerBootstrapped = true;170+  try {171+    const { setSyncRunner, hydratePersistedSyncState } = await import("./sync-trigger");172+    const { productionSyncRunner } = await import("./chirp-sync-runner");173+    setSyncRunner(productionSyncRunner);174+    await hydratePersistedSyncState();175+  } catch (err) {176+    nativeSyncRunnerBootstrapped = false;177+    console.warn("[Peach] failed to bootstrap native sync runner:", err);178+  }161179 }162180 163181 function isMessage(m: unknown): m is Message {

view file

src/background/sync-trigger.ts+39 −18
@@ -38,22 +38,39 @@3838 3939 export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4040 41-let syncInFlight = false;42-let lastState: SyncState = { status: "idle" };43-let runner: RunSyncWithDevice | null = null;41+interface PeachSyncTriggerState {42+  syncInFlight: boolean;43+  lastState: SyncState;44+  runner: RunSyncWithDevice | null;45+}4446 47+function getSyncTriggerState(): PeachSyncTriggerState {48+  const g = globalThis as typeof globalThis & {49+    __peachSyncTriggerState?: PeachSyncTriggerState;50+  };51+  if (!g.__peachSyncTriggerState) {52+    g.__peachSyncTriggerState = {53+      syncInFlight: false,54+      lastState: { status: "idle" },55+      runner: null,56+    };57+  }58+  return g.__peachSyncTriggerState;59+}60+4561 export function setSyncRunner(fn: RunSyncWithDevice | null): void {46-  runner = fn;62+  getSyncTriggerState().runner = fn;4763 }4864 4965 export function _resetSyncTriggerForTest(): void {50-  syncInFlight = false;51-  lastState = { status: "idle" };52-  runner = null;66+  const state = getSyncTriggerState();67+  state.syncInFlight = false;68+  state.lastState = { status: "idle" };69+  state.runner = null;5370 }5471 5572 export function getSyncState(): SyncState {56-  return lastState;73+  return getSyncTriggerState().lastState;5774 }5875 5976 /**@@ -63,10 +80,11 @@6380  * time the SW respawns. Called once at startup from `entry.ts`.6481  */6582 export async function hydratePersistedSyncState(): Promise<void> {66-  if (lastState.lastSyncAt !== undefined) return;83+  const state = getSyncTriggerState();84+  if (state.lastState.lastSyncAt !== undefined) return;6785   const persisted = await getLastSyncAt();6886   if (typeof persisted === "number") {69-    lastState = { ...lastState, lastSyncAt: persisted };87+    state.lastState = { ...state.lastState, lastSyncAt: persisted };7088   }7189 }7290 @@ -74,11 +92,12 @@7492   reason: SyncTriggerReason;7593   force?: boolean;7694 }): Promise<void> {95+  const state = getSyncTriggerState();7796   // Claim the guard synchronously before any await, otherwise two7897   // concurrent triggers could both pass the gate before either reached7998   // the registry lookup.80-  if (syncInFlight && !opts.force) return;81-  syncInFlight = true;99+  if (state.syncInFlight && !opts.force) return;100+  state.syncInFlight = true;82101 83102   try {84103     const devices = await listPairedDevices();@@ -88,17 +107,18 @@88107         d.trustSource === "direct",89108     );90109     if (hosts.length === 0) return;91-    if (!runner) return;110+    if (!state.runner) return;92111 93112     setState({ status: "syncing", reason: opts.reason, error: undefined });94113     await runSyncRounds(hosts);95114   } finally {96-    syncInFlight = false;115+    state.syncInFlight = false;97116   }98117 }99118 100119 async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {101-  if (!runner) return;120+  const state = getSyncTriggerState();121+  if (!state.runner) return;102122 103123   let entriesReceived = 0;104124   let entriesSent = 0;@@ -107,7 +127,7 @@107127 108128   for (const host of hosts) {109129     try {110-      const result = await runner(host);130+      const result = await state.runner(host);111131       if (!result.ok) {112132         if (firstError === undefined) firstError = result.error;113133         continue;@@ -146,8 +166,9 @@146166 }147167 148168 function setState(patch: Partial<SyncState>): void {149-  lastState = { ...lastState, ...patch };150-  broadcast(lastState);169+  const state = getSyncTriggerState();170+  state.lastState = { ...state.lastState, ...patch };171+  broadcast(state.lastState);151172 }152173 153174 function broadcast(state: SyncState): void {

view file

src/popup/App.tsx+11 −0
@@ -518,6 +518,17 @@518518       // .then() callback may miss the capture window and leak the listener.519519       let stopMDNS: () => void = () => undefined;520520       onCleanup(() => stopMDNS());521+      if (isNativePlatform()) {522+        // Android runs the background/sync dispatcher in-process with the523+        // popup, so the production runner has to be registered from the524+        // UI bootstrap path instead of the MV3 service-worker entry.525+        void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>526+          import("../background/chirp-sync-runner").then(({ productionSyncRunner }) => {527+            setSyncRunner(productionSyncRunner);528+            void hydratePersistedSyncState();529+          })530+        );531+      }521532       void import("./chirp-pair-capacitor").then(({ startMDNSListener }) => {522533         stopMDNS = startMDNSListener();523534       });

view file