89df69da73ab
auto-advertise lifecycle + incoming pairing subscriber wired into entry.ts (v2.14.3)
- time
- 2026-04-30 20:38 UTC · 6 months ago
- machine
- koh2
- parent
- ccf8a297e1d5
↓ peach-browser-89df69da.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.ts+5 −0src/background/entry.ts+22 −0src/background/messages.ts+4 −0src/background/sync-lifecycle.ts+234 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.14.2",4+ "version": "2.14.3",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",4- "version": "2.14.2",4+ "version": "2.14.3",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "2.14.2",3+ "version": "2.14.3",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/alarms.ts+5 −0
@@ -32,6 +32,11 @@3232 }3333 3434 function broadcastLocked(): void {35+ // Notify the sync lifecycle so advertising stops while locked. Lazy-import36+ // to avoid pulling sync.ts into every consumer of alarms.37+ void import("./sync-lifecycle")38+ .then((m) => m.onVaultLocked())39+ .catch(() => {});3540 // Reach the popup (and any other extension UI context) via runtime.3641 chrome.runtime?.sendMessage?.({ type: "VAULT_LOCKED" })?.catch?.(() => {});3742 // Reach content scripts via tabs. Tabs without Peach content scriptssrc/background/entry.ts+22 −0
@@ -1,3 +1,4 @@1+import { Capacitor } from "@capacitor/core";12 import { setupMessageHandler } from "./messages";23 import { setupAutoLock } from "./alarms";34 import { setupPasskeyProxy } from "./passkey";@@ -9,11 +10,32 @@910 // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural1011 // home for auto-lock timing). Keeping it in only one place avoids drift.1112 13+async function setupSyncLifecycle(): Promise<void> {14+ if (Capacitor.isNativePlatform()) return;15+ try {16+ const sync = await import("./sync");17+ const lifecycle = await import("./sync-lifecycle");18+ // Register the incoming pairing subscriber once. Best-effort: subsequent19+ // sync.connectSyncHost / advertise calls trigger PEER_CONNECTED events20+ // that route through the subscriber.21+ lifecycle.setupIncomingPairingSubscriber();22+ try {23+ sync.connectSyncHost();24+ lifecycle.onNativeHostConnected();25+ } catch {26+ // Native host not installed yet — lifecycle will engage when it is.27+ }28+ } catch (err) {29+ console.warn("setupSyncLifecycle failed:", err);30+ }31+}32+1233 export function start() {1334 setupMessageHandler();1435 void setupAutoLock();1536 setupCommands();1637 void setupPasskeyProxy();38+ void setupSyncLifecycle();1739 }1840 1941 start();src/background/messages.ts+4 −0
@@ -12,6 +12,7 @@1212 import { copyToClipboard } from "./clipboard";1313 import { setupAutoLock, touchAutoLock } from "./alarms";1414 import * as vault from "./vault";15+import * as syncLifecycle from "./sync-lifecycle";1516 import {1617 activateLicense,1718 deactivateLicense,@@ -227,6 +228,7 @@227228 await vault.createVault(password);228229 await setupAutoLock();229230 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });231+ void syncLifecycle.onVaultUnlocked();230232 return { ok: true };231233 }232234 case "UNLOCK": {@@ -235,11 +237,13 @@235237 if (ok) {236238 await setupAutoLock();237239 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });240+ void syncLifecycle.onVaultUnlocked();238241 }239242 return { ok };240243 }241244 case "LOCK":242245 vault.lock();246+ void syncLifecycle.onVaultLocked();243247 return { ok: true };244248 case "IS_UNLOCKED":245249 return { ok: true, data: vault.isUnlocked() };src/background/sync-lifecycle.ts+234 −0
@@ -0,0 +1,234 @@1+/**2+ * Sync lifecycle — auto-advertise + incoming pairing subscriber.3+ *4+ * Keeps mDNS advertisement in sync with vault state and license:5+ * - Pro + unlocked + at least one paired device → advertise6+ * - Anything missing → stop advertising7+ *8+ * Also listens for incoming pairing-only connections from the native host,9+ * reads the first PAIR_REQUEST frame, stores the request in session storage10+ * (with the connection id) and pings the popup so the banner appears.11+ *12+ * The sync stack is best-effort: if the native host is not installed yet,13+ * every advertise/listen call no-ops gracefully and the lifecycle still14+ * progresses through state changes for when the host arrives.15+ */16+17+import { Capacitor } from "@capacitor/core";18+import * as sync from "./sync";19+import { isProUser } from "./license";20+import * as vault from "./vault";21+import { getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22+import { decodeSyncMessage } from "../shared/sync-wire";23+24+const DEVICE_ID_KEY = "peach_device_id";25+const DEVICE_NAME_KEY = "peach_device_name";26+27+let advertisingActive = false;28+let nativeHostConnected = false;29+let incomingSubscriberInstalled = false;30+31+async function getOrCreateDeviceId(): Promise<string> {32+ try {33+ const stored = await chrome.storage.local.get(DEVICE_ID_KEY);34+ const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];35+ if (typeof value === "string" && value) return value;36+ } catch {37+ // fall through38+ }39+ // base64url-encoded 16-byte random — matches the byte length the native40+ // host expects (see Advertise message decoding).41+ const random = crypto.getRandomValues(new Uint8Array(16));42+ let binary = "";43+ for (const byte of random) binary += String.fromCharCode(byte);44+ const id = btoa(binary)45+ .replace(/\+/g, "-")46+ .replace(/\//g, "_")47+ .replace(/=+$/g, "");48+ try {49+ await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });50+ } catch {51+ // best-effort52+ }53+ return id;54+}55+56+async function getDeviceName(): Promise<string> {57+ try {58+ const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);59+ const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];60+ if (typeof value === "string" && value.trim()) return value.trim();61+ } catch {62+ // fall through63+ }64+ if (Capacitor.getPlatform() === "android") return "Peach Android";65+ if (Capacitor.getPlatform() === "ios") return "Peach iOS";66+ return "Peach";67+}68+69+/**70+ * Reconcile mDNS advertisement with current state. Idempotent.71+ */72+export async function syncLifecycleTick(): Promise<void> {73+ // Capacitor (Android) does not run a native messaging host; sync there74+ // happens via the Capacitor bridge which manages its own discovery.75+ if (Capacitor.isNativePlatform()) return;76+77+ if (!nativeHostConnected) {78+ advertisingActive = false;79+ return;80+ }81+82+ let shouldAdvertise = false;83+ try {84+ const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]);85+ shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0;86+ } catch {87+ shouldAdvertise = false;88+ }89+90+ if (shouldAdvertise && !advertisingActive) {91+ try {92+ const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]);93+ await sync.advertise({ deviceId, name });94+ advertisingActive = true;95+ } catch (err) {96+ console.warn("sync advertise failed:", err);97+ }98+ } else if (!shouldAdvertise && advertisingActive) {99+ try {100+ await sync.stopAdvertising();101+ } catch {102+ // best-effort103+ }104+ advertisingActive = false;105+ }106+}107+108+/**109+ * Subscribe once to incoming sync connections from the native host. Routes110+ * pairing-only connections into the session-stored IncomingPairingRequest the111+ * popup banner reads from; data-sync connections are handed off to a112+ * runBlobSyncSession in responder mode.113+ */114+export function setupIncomingPairingSubscriber(): void {115+ if (incomingSubscriberInstalled) return;116+ incomingSubscriberInstalled = true;117+118+ sync.onIncomingConnection((info) => {119+ void handleIncomingConnection(info);120+ });121+}122+123+async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {124+ if (info.isPairingOnly) {125+ await waitForPairRequest(info);126+ return;127+ }128+ // Trusted data-sync connection — accept and run a responder session.129+ await runResponderSession(info.connectionId);130+}131+132+async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise<void> {133+ const unsubscribe = sync.onSyncMessage((id, frame) => {134+ if (id !== info.connectionId) return;135+ unsubscribe();136+ let publicKeyHex = info.peerPublicKeyHex ?? "";137+ let deviceName = "Unknown device";138+ try {139+ const decoded = decodeSyncMessage(frame);140+ if (decoded.type === "PAIR_REQUEST") {141+ publicKeyHex = decoded.public_key_hex || publicKeyHex;142+ deviceName = decoded.device_name || deviceName;143+ } else {144+ // Unexpected first frame on a pairing-only connection — drop.145+ try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }146+ return;147+ }148+ } catch {149+ try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }150+ return;151+ }152+153+ const now = Date.now();154+ void setIncomingPairingRequest({155+ deviceName,156+ publicKeyHex,157+ receivedAt: now,158+ expiresAt: now + 60_000,159+ connectionId: info.connectionId,160+ });161+162+ // Tell the popup. If it isn't open the runtime message no-ops; the163+ // request still sits in session storage for the next popup open.164+ chrome.runtime165+ .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })166+ .catch(() => {});167+ });168+169+ // Auto-cleanup if the connection drops before any frame arrives.170+ const offClose = sync.onDisconnected((id) => {171+ if (id === undefined || id === info.connectionId) {172+ try { unsubscribe(); } catch { /* best-effort */ }173+ try { offClose(); } catch { /* best-effort */ }174+ }175+ });176+}177+178+async function runResponderSession(connectionId: string): Promise<void> {179+ try {180+ const { createNativeSyncTransport, runBlobSyncSession } = await import(181+ "./sync-coordinator"182+ );183+ const { storageGet } = await import("../shared/storage");184+ const transport = await createNativeSyncTransport(connectionId);185+ await runBlobSyncSession("responder", transport, {186+ isPro: () => isProUser(),187+ isUnlocked: () => vault.isUnlocked(),188+ getVaultBlobs: async () => ({189+ vault_a: (await storageGet("peach_vault_a")) ?? "",190+ vault_b: (await storageGet("peach_vault_b")) ?? "",191+ }),192+ });193+ } catch (err) {194+ console.warn("responder sync session failed:", err);195+ }196+}197+198+// ---------------------------------------------------------------------------199+// External-event hooks. The background's lifecycle layer (entry.ts) calls200+// these on relevant transitions so the lifecycle tick is always coherent.201+// ---------------------------------------------------------------------------202+203+export function onNativeHostConnected(): void {204+ nativeHostConnected = true;205+ setupIncomingPairingSubscriber();206+ void syncLifecycleTick();207+}208+209+export function onNativeHostDisconnected(): void {210+ nativeHostConnected = false;211+ advertisingActive = false;212+}213+214+export async function onVaultUnlocked(): Promise<void> {215+ await syncLifecycleTick();216+}217+218+export async function onVaultLocked(): Promise<void> {219+ await syncLifecycleTick();220+}221+222+export async function onProActivated(): Promise<void> {223+ await syncLifecycleTick();224+}225+226+/**227+ * Test-only — reset the module-local mutable state so each test starts from a228+ * known baseline. Production code never calls this.229+ */230+export function __resetForTests(): void {231+ advertisingActive = false;232+ nativeHostConnected = false;233+ incomingSubscriberInstalled = false;234+}