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
↓ peach-browser-8266c804.tar.gzatlas at this save
files changed7 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 −0package-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",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": {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 });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 }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 {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 {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 });