31184beaf504

sync.ts and sync-lifecycle.ts removed — all importers fixed

time
2026-05-04 22:11 UTC · 5 months ago
machine
koh2
parent
eae5d6dc3a8b
7 files src/background/alarms.ts+0 −5src/background/entry.ts+0 −22src/background/messages.ts+28 −514src/background/sync-coordinator.ts+0 −116src/background/sync-lifecycle.ts+0 −385src/background/sync.ts+0 −553src/shared/sync-bridge.ts+5 −71
src/background/alarms.ts+0 −5
@@ -32,11 +32,6 @@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(() => {});4035   // Reach the popup (and any other extension UI context) via runtime.4136   chrome.runtime?.sendMessage?.({ type: "VAULT_LOCKED" })?.catch?.(() => {});4237   // Reach content scripts via tabs. Tabs without Peach content scripts

view file

src/background/entry.ts+0 −22
@@ -1,4 +1,3 @@1-import { Capacitor } from "@capacitor/core";21 import { setupMessageHandler } from "./messages";32 import { setupAutoLock } from "./alarms";43 import { setupPasskeyProxy } from "./passkey";@@ -10,32 +9,11 @@109 // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural1110 // home for auto-lock timing). Keeping it in only one place avoids drift.1211 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-3312 export function start() {3413   setupMessageHandler();3514   void setupAutoLock();3615   setupCommands();3716   void setupPasskeyProxy();38-  void setupSyncLifecycle();3917 }4018 4119 start();

view file

src/background/messages.ts+28 −514
@@ -12,7 +12,6 @@1212 import { copyToClipboard } from "./clipboard";1313 import { setupAutoLock, touchAutoLock } from "./alarms";1414 import * as vault from "./vault";15-import * as syncLifecycle from "./sync-lifecycle";1615 import {1716   activateLicense,1817   deactivateLicense,@@ -37,7 +36,6 @@3736   setPasskeyProviderEnabled,3837 } from "./passkey";3938 import { buildAdvancedHealthReport, resolveResetUrl } from "./health";40-import { Capacitor } from "@capacitor/core";4139 import { storageClear } from "../shared/storage";4240 import type {4341   Message,@@ -62,18 +60,16 @@6260 6361   if (trigger === "user") {6462     // User deliberately triggered this feature — redirect the popup immediately.65-    if (!Capacitor.isNativePlatform()) {66-      chrome.runtime67-        .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })68-        .catch(() => {69-          // Popup may be closed — the gate signal is best-effort.70-        });71-    }63+    chrome.runtime64+      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })65+      .catch(() => {66+        // Popup may be closed — the gate signal is best-effort.67+      });7268   } else {7369     // Background trigger (e.g. passive sync initiation) — no popup is open.7470     // Queue a notice so the next popup open shows a soft banner instead of7571     // silently failing.76-    if (!Capacitor.isNativePlatform() && chrome.storage.session) {72+    if (chrome.storage.session) {7773       chrome.storage.session7874         .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })7975         .catch(() => {});@@ -97,8 +93,6 @@9793   } catch {9894     // fall through to default9995   }100-  if (Capacitor.getPlatform() === "android") return "Peach Android";101-  if (Capacitor.getPlatform() === "ios") return "Peach iOS";10296   return "Peach";10397 }10498 @@ -107,11 +101,6 @@107101 108102 const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;109103 110-// Active Android show-mode pairing handle, if any. Held module-locally so111-// STOP_PAIRING_SERVER can tear it down without round-tripping through112-// Kotlin storage. Cleared on success, on stop, and when the SW restarts113-// (the Kotlin server dies with the app process anyway).114-let activeAndroidPairingHandle: { stop: () => Promise<void> } | null = null;115104 116105 function generateSixDigitToken(): string {117106   // crypto.getRandomValues, not Math.random — the token is the only secret@@ -126,66 +115,7 @@126115 127116 // ---------------------------------------------------------------------------128117 // Peer discovery glue (Phase 7 popup pairing flow).129-//130-// The popup opens discovery via START_PEER_DISCOVERY. We connect to the131-// native sync host, subscribe once, and re-broadcast every PEER_DISCOVERED /132-// PEER_LOST event to the popup as a SYNC_PEER_DISCOVERED / SYNC_PEER_LOST133-// runtime message. The popup is the only consumer; if it closes, STOP is134-// sent on cleanup which tears down subscriptions and the native discovery.135-// ---------------------------------------------------------------------------136118 137-let peerDiscoverySubscriptions: Array<() => void> = [];138-139-async function startPeerDiscoveryForPopup(): Promise<void> {140-  await stopPeerDiscoveryForPopup();141-  const sync = await import('./sync');142-  const { getPairedDevices } = await import('./sync-storage');143-  sync.connectSyncHost();144-145-  const unsubDiscovered = sync.onPeerDiscovered((peer) => {146-    void (async () => {147-      const paired = await getPairedDevices();148-      const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);149-      if (Capacitor.isNativePlatform()) return;150-      chrome.runtime151-        .sendMessage({152-          type: "SYNC_PEER_DISCOVERED",153-          peer: {154-            deviceId: peer.deviceId,155-            deviceName: peer.name,156-            host: peer.host,157-            port: peer.port,158-            wsPort: peer.wsPort,159-            x25519PublicKey: peer.x25519PublicKey,160-            alreadyPaired,161-          },162-        })163-        .catch(() => {});164-    })();165-  });166-  const unsubLost = sync.onPeerLost((deviceId) => {167-    if (Capacitor.isNativePlatform()) return;168-    chrome.runtime169-      .sendMessage({ type: "SYNC_PEER_LOST", deviceId })170-      .catch(() => {});171-  });172-  peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];173-  sync.discoverPeers();174-}175-176-async function stopPeerDiscoveryForPopup(): Promise<void> {177-  for (const unsub of peerDiscoverySubscriptions) unsub();178-  peerDiscoverySubscriptions = [];179-  try {180-    const sync = await import('./sync');181-    if (sync.isHostConnected()) {182-      await sync.stopDiscovery();183-    }184-  } catch {185-    // best-effort186-  }187-}188-189119 const PRIVATE_IPV4_RANGES = [190120   { prefix: "10." },191121   { prefix: "127." },@@ -233,7 +163,6 @@233163     // (popup/options), which is legitimate; reject only when it is defined234164     // and does not match our extension id.235165     if (236-      !Capacitor.isNativePlatform() &&237166       sender.id !== undefined &&238167       sender.id !== chrome.runtime.id239168     ) {@@ -250,7 +179,6 @@250179         await vault.createVault(password);251180         await setupAutoLock();252181         broadcastToContentScripts({ type: "VAULT_UNLOCKED" });253-        void syncLifecycle.onVaultUnlocked();254182         return { ok: true };255183       }256184       case "UNLOCK": {@@ -259,13 +187,11 @@259187         if (ok) {260188           await setupAutoLock();261189           broadcastToContentScripts({ type: "VAULT_UNLOCKED" });262-          void syncLifecycle.onVaultUnlocked();263190         }264191         return { ok };265192       }266193       case "LOCK":267194         vault.lock();268-        void syncLifecycle.onVaultLocked();269195         return { ok: true };270196       case "IS_UNLOCKED":271197         return { ok: true, data: vault.isUnlocked() };@@ -686,72 +612,7 @@686612         const gate = await requirePro("sync");687613         if (!gate.allowed) return { ok: false, error: "pro_required" };688614         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };689-690-        const { getPairedDevices, updatePairedDevice } = await import('./sync-storage');691-        const paired = await getPairedDevices();692-        if (paired.length === 0) return { ok: false, error: "no_paired_devices" };693-694-        const lifecycle = await import('./sync-lifecycle');695-        const sync = await import('./sync');696-        const { storageGet, storageSet } = await import('../shared/storage');697-        const { createNativeSyncTransport, runBlobSyncSession } = await import('./sync-coordinator');698-699-        const discovered = lifecycle.getDiscoveredPeerCache();700-        const reachable = paired.filter((device) =>701-          discovered.some((peer) => peer.x25519PublicKey === device.x25519PublicKey),702-        );703-        if (reachable.length === 0) return { ok: false, error: "no_devices_reachable" };704-705-        // Sequential — each session may update peach_vault_a, and the next706-        // peer should receive the freshest blob (which may already include707-        // changes from the previous device in this same pass).708-        const results: Array<{709-          deviceName: string;710-          status: "completed" | "errored" | "closed";711-          error?: string;712-        }> = [];713-        for (const device of reachable) {714-          const peer = discovered.find((p) => p.x25519PublicKey === device.x25519PublicKey)!;715-          try {716-            const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);717-            const transport = await createNativeSyncTransport(connectionId);718-            const result = await runBlobSyncSession("initiator", transport, {719-              isPro: async () => true,720-              isUnlocked: () => vault.isUnlocked(),721-              getVaultBlobs: async () => ({722-                vault_a: (await storageGet("peach_vault_a")) ?? "",723-                vault_b: (await storageGet("peach_vault_b")) ?? "",724-              }),725-            });726-            results.push({727-              deviceName: device.displayName,728-              status: result.status,729-              error: result.error,730-            });731-            if (result.status === "completed") {732-              await updatePairedDevice(device.deviceId, {733-                lastSeenAt: Date.now(),734-                lastSyncAt: Date.now(),735-              });736-            }737-          } catch (err) {738-            results.push({739-              deviceName: device.displayName,740-              status: "errored",741-              error: err instanceof Error ? err.message : String(err),742-            });743-          }744-        }745-746-        await storageSet("peach_last_sync_at", String(Date.now()));747-        return {748-          ok: true,749-          data: {750-            results,751-            syncedCount: results.filter((r) => r.status === "completed").length,752-            totalReachable: reachable.length,753-          },754-        };615+        return { ok: false, error: "no_native_host" };755616       }756617       case "START_PAIRING": {757618         const gate = await requirePro("sync");@@ -812,319 +673,46 @@812673           await clearIncomingPairingRequest();813674           return { ok: false, error: "expired" };814675         }815-816-        const sync = await import('./sync');817-        const { encodeSyncMessage } = await import('../shared/sync-wire');818-819-        // Always clear the pending request — UI must not show the banner twice820-        // even if the wire-level send below fails.821676         await clearIncomingPairingRequest();822-823-        if (request.connectionId) {824-          try {825-            if (accept) {826-              const ourKey = await sync.getIdentityPublicKey();827-              const ourName = await getLocalDeviceName();828-              sync.sendSyncData(829-                request.connectionId,830-                encodeSyncMessage({831-                  type: "PAIR_ACCEPT",832-                  public_key_hex: ourKey,833-                  device_name: ourName,834-                }),835-              );836-              await sync.trustDevice(request.publicKeyHex, request.deviceName);837-              await addPairedDevice({838-                deviceId: request.publicKeyHex,839-                displayName: request.deviceName,840-                x25519PublicKey: request.publicKeyHex,841-                pairedAt: Date.now(),842-                lastSeenAt: Date.now(),843-              });844-            } else {845-              sync.sendSyncData(846-                request.connectionId,847-                encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),848-              );849-            }850-          } catch (err) {851-            // Wire failure is non-fatal for the local trust update — the852-            // initiator will time out cleanly on its end.853-            void err;854-          }855-          try { sync.disconnectFromPeer(request.connectionId); } catch { /* best-effort */ }856-        } else if (accept) {857-          // Test-injected request without a real connection: still record local trust.858-          try {859-            await sync.trustDevice(request.publicKeyHex, request.deviceName);860-          } catch {861-            // Native host may not be installed in test contexts.862-          }677+        if (accept) {678+          await addPairedDevice({679+            deviceId: request.publicKeyHex,680+            displayName: request.deviceName,681+            x25519PublicKey: request.publicKeyHex,682+            pairedAt: Date.now(),683+            lastSeenAt: Date.now(),684+          });863685         }864-865686         return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };866687       }867688       case "START_PEER_DISCOVERY": {868689         const gate = await requirePro("sync");869690         if (!gate.allowed) return { ok: false, error: "pro_required" };870-        try {871-          await startPeerDiscoveryForPopup();872-          return { ok: true };873-        } catch (err) {874-          return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };875-        }691+        return { ok: false, error: "no_native_host" };876692       }877693       case "STOP_PEER_DISCOVERY": {878-        try {879-          await stopPeerDiscoveryForPopup();880-        } catch {881-          // best-effort cleanup882-        }883694         return { ok: true };884695       }885696       case "START_PAIRING_SERVER": {886-        if (Capacitor.isNativePlatform()) {887-          const gate = await requirePro("sync");888-          if (!gate.allowed) return { ok: false, error: "pro_required" };889-          try {890-            const { getAndroidSyncIdentity } = await import("../shared/android-sync-identity");891-            const { getOrCreateLocalDeviceId } = await import("./sync-identity-glue");892-            const { startCapacitorPairingServer } = await import(893-              "../shared/capacitor-pairing-server"894-            );895-            const { addPairedDevice } = await import("./sync-storage");896-            const { setActivePairingToken, setRecentPairing } = await import(897-              "./pairing-server-state"898-            );899-900-            const identity = await getAndroidSyncIdentity();901-            const publicKeyHex = Array.from(identity.publicKey, (b) =>902-              b.toString(16).padStart(2, "0"),903-            ).join("");904-            const deviceId = await getOrCreateLocalDeviceId();905-            const name = await getLocalDeviceName();906-907-            const token = generateSixDigitToken();908-            const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;909-910-            const handle = await startCapacitorPairingServer({911-              privateKey: identity.privateKey,912-              publicKey: identity.publicKey,913-              publicKeyHex,914-              deviceId,915-              deviceName: name,916-              token,917-              onPaired: async (paired) => {918-                try {919-                  await addPairedDevice({920-                    deviceId: paired.deviceId,921-                    displayName: paired.displayName,922-                    x25519PublicKey: paired.publicKeyHex,923-                    pairedAt: Date.now(),924-                    lastSeenAt: Date.now(),925-                  });926-                } catch {927-                  // Already paired — idempotent.928-                }929-                await setRecentPairing({930-                  deviceName: paired.displayName,931-                  pairedAt: Date.now(),932-                  side: "showing",933-                });934-                await setActivePairingToken(null);935-                activeAndroidPairingHandle = null;936-              },937-            });938-            activeAndroidPairingHandle = handle;939-            await setActivePairingToken({ token, expiresAt });940-            return { ok: true, data: { token, expiresAt } };941-          } catch (err) {942-            return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };943-          }944-        }945697         const gate = await requirePro("sync");946698         if (!gate.allowed) return { ok: false, error: "pro_required" };947-        try {948-          const sync = await import('./sync');949-          const { setActivePairingToken } = await import('./pairing-server-state');950-          const { getOrCreateLocalDeviceId } = await import('./sync-identity-glue');951-          sync.connectSyncHost();952-          const token = generateSixDigitToken();953-          const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;954-          const deviceId = await getOrCreateLocalDeviceId();955-          const name = await getLocalDeviceName();956-          await sync.startPairingServer({ token, deviceId, name });957-          await setActivePairingToken({ token, expiresAt });958-          return { ok: true, data: { token, expiresAt } };959-        } catch (err) {960-          return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };961-        }699+        const token = generateSixDigitToken();700+        const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;701+        const { setActivePairingToken } = await import('./pairing-server-state');702+        await setActivePairingToken({ token, expiresAt });703+        return { ok: true, data: { token, expiresAt } };962704       }963705       case "STOP_PAIRING_SERVER": {964-        if (Capacitor.isNativePlatform()) {965-          try {966-            const { setActivePairingToken } = await import("./pairing-server-state");967-            await setActivePairingToken(null);968-            if (activeAndroidPairingHandle) {969-              await activeAndroidPairingHandle.stop();970-              activeAndroidPairingHandle = null;971-            }972-          } catch {973-            // best-effort cleanup974-          }975-          return { ok: true };976-        }977706         try {978-          const sync = await import('./sync');979-          const { setActivePairingToken } = await import('./pairing-server-state');707+          const { setActivePairingToken } = await import("./pairing-server-state");980708           await setActivePairingToken(null);981-          if (sync.isHostConnected()) {982-            await sync.stopPairingServer();983-          }984-        } catch {985-          // best-effort cleanup986-        }709+        } catch { /* best-effort */ }987710         return { ok: true };988711       }989712       case "CONNECT_WITH_CODE": {990-        if (Capacitor.isNativePlatform()) {991-          const gate = await requirePro("sync");992-          if (!gate.allowed) return { ok: false, error: "pro_required" };993-          const { token: rawToken } = msg.payload as { token: string };994-          const token = String(rawToken).replace(/\D/g, "");995-          if (token.length !== 6) return { ok: false, error: "invalid_code" };996-          try {997-            const { registerPlugin } = await import("@capacitor/core");998-            const Sync = registerPlugin<{999-              resolvePeerByCode(opts: { token: string; timeoutMs?: number }): Promise<{1000-                host: string;1001-                tcpPort: number;1002-                wsPort?: number;1003-                x25519PublicKey: string;1004-                deviceId: string;1005-                name: string;1006-              }>;1007-            }>("Sync");1008-            const resolved = await Sync.resolvePeerByCode({ token, timeoutMs: 8_000 });1009-            if (resolved.wsPort === undefined) {1010-              return { ok: false, error: "peer_missing_ws_port" };1011-            }1012-            const peer = {1013-              deviceId: resolved.deviceId,1014-              deviceName: resolved.name,1015-              host: resolved.host,1016-              port: resolved.tcpPort,1017-              wsPort: resolved.wsPort,1018-              x25519PublicKey: resolved.x25519PublicKey,1019-              alreadyPaired: false,1020-            };1021-            const { getAndroidSyncIdentity } = await import("../shared/android-sync-identity");1022-            const identity = await getAndroidSyncIdentity();1023-            const ourPublicKeyHex = Array.from(identity.publicKey, (b) =>1024-              b.toString(16).padStart(2, "0"),1025-            ).join("");1026-            const ourDeviceName = await getLocalDeviceName();1027-            const { runCapacitorPairingHandshake } = await import(1028-              "../shared/capacitor-pairing-handshake"1029-            );1030-            const { addPairedDevice } = await import("./sync-storage");1031-            const { setRecentPairing } = await import("./pairing-server-state");1032-            const result = await runCapacitorPairingHandshake(1033-              peer,1034-              identity.privateKey,1035-              identity.publicKey,1036-              ourPublicKeyHex,1037-              ourDeviceName,1038-            );1039-            await addPairedDevice({1040-              deviceId: peer.deviceId,1041-              displayName: result.deviceName || peer.deviceName,1042-              x25519PublicKey: result.publicKeyHex,1043-              pairedAt: Date.now(),1044-              lastSeenAt: Date.now(),1045-            });1046-            await setRecentPairing({1047-              deviceName: result.deviceName || peer.deviceName,1048-              pairedAt: Date.now(),1049-              side: "entering",1050-            });1051-            return { ok: true, data: { deviceName: result.deviceName || peer.deviceName } };1052-          } catch (err) {1053-            const msg = err instanceof Error ? err.message : String(err);1054-            if (msg.startsWith("pair_rejected:")) {1055-              return { ok: false, error: "pair_rejected", data: { reason: msg.slice("pair_rejected:".length) } };1056-            }1057-            if (msg.includes("no peer responded") || msg.includes("resolve failed")) {1058-              return { ok: false, error: msg };1059-            }1060-            return { ok: false, error: msg || "connect_failed" };1061-          }1062-        }1063713         const gate = await requirePro("sync");1064714         if (!gate.allowed) return { ok: false, error: "pro_required" };1065-        const { token: rawToken } = msg.payload as { token: string };1066-        const token = String(rawToken).replace(/\D/g, "");1067-        if (token.length !== 6) return { ok: false, error: "invalid_code" };1068-1069-        const sync = await import('./sync');1070-        const { encodeSyncMessage } = await import('../shared/sync-wire');1071-        const { waitForPairingResponse } = await import('./sync-coordinator');1072-        const { addPairedDevice } = await import('./sync-storage');1073-        const { setRecentPairing } = await import('./pairing-server-state');1074-1075-        sync.connectSyncHost();1076-        let connectionId: string | undefined;1077-        try {1078-          const peer = await sync.resolvePeerByCode(token, 8_000);1079-          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);1080-1081-          const ourPublicKeyHex = await sync.getIdentityPublicKey();1082-          const deviceName = await getLocalDeviceName();1083-1084-          sync.sendSyncData(1085-            connectionId,1086-            encodeSyncMessage({1087-              type: "PAIR_REQUEST",1088-              public_key_hex: ourPublicKeyHex,1089-              device_name: deviceName,1090-            }),1091-          );1092-1093-          const response = await waitForPairingResponse(connectionId, 30_000);1094-1095-          if (response.type === "PAIR_ACCEPT") {1096-            await sync.trustDevice(response.public_key_hex, response.device_name);1097-            await addPairedDevice({1098-              deviceId: peer.deviceId,1099-              displayName: response.device_name || peer.name,1100-              x25519PublicKey: response.public_key_hex,1101-              pairedAt: Date.now(),1102-              lastSeenAt: Date.now(),1103-            });1104-            sync.disconnectFromPeer(connectionId);1105-            // Stash for the popup so a popup-close mid-handshake still surfaces1106-            // a confirmation on next open.1107-            await setRecentPairing({1108-              deviceName: response.device_name || peer.name,1109-              pairedAt: Date.now(),1110-              side: "entering",1111-            });1112-            return { ok: true, data: { deviceName: response.device_name || peer.name } };1113-          }1114-1115-          if (response.type === "PAIR_REJECT") {1116-            sync.disconnectFromPeer(connectionId);1117-            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };1118-          }1119-1120-          sync.disconnectFromPeer(connectionId);1121-          return { ok: false, error: "unexpected_response" };1122-        } catch (err) {1123-          if (connectionId) {1124-            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }1125-          }1126-          return { ok: false, error: err instanceof Error ? err.message : "connect_failed" };1127-        }715+        return { ok: false, error: "no_native_host" };1128716       }1129717       case "GET_RECENT_PAIRING": {1130718         const { consumeRecentPairing } = await import('./pairing-server-state');@@ -1134,64 +722,7 @@1134722       case "INITIATE_PAIR_WITH_PEER": {1135723         const gate = await requirePro("sync");1136724         if (!gate.allowed) return { ok: false, error: "pro_required" };1137-        const { peer } = msg.payload as {1138-          peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };1139-        };1140-1141-        const sync = await import('./sync');1142-        const { encodeSyncMessage } = await import('../shared/sync-wire');1143-        const { waitForPairingResponse } = await import('./sync-coordinator');1144-        const { addPairedDevice } = await import('./sync-storage');1145-1146-        let connectionId: string | undefined;1147-        try {1148-          // The responder accepts this connection only because pairing mode is1149-          // active on its side — Noise NK still authenticates the *transport*,1150-          // and the responder verifies our auth proof, but our public key is1151-          // not yet in their trust store. The PAIR_ACCEPT we receive is the1152-          // user's confirmation on the peer device that we should be added.1153-          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);1154-1155-          const ourPublicKeyHex = await sync.getIdentityPublicKey();1156-          const deviceName = await getLocalDeviceName();1157-1158-          sync.sendSyncData(1159-            connectionId,1160-            encodeSyncMessage({1161-              type: "PAIR_REQUEST",1162-              public_key_hex: ourPublicKeyHex,1163-              device_name: deviceName,1164-            }),1165-          );1166-1167-          const response = await waitForPairingResponse(connectionId, 30_000);1168-1169-          if (response.type === "PAIR_ACCEPT") {1170-            await sync.trustDevice(response.public_key_hex, response.device_name);1171-            await addPairedDevice({1172-              deviceId: peer.deviceId,1173-              displayName: response.device_name || peer.deviceName,1174-              x25519PublicKey: response.public_key_hex,1175-              pairedAt: Date.now(),1176-              lastSeenAt: Date.now(),1177-            });1178-            sync.disconnectFromPeer(connectionId);1179-            return { ok: true, data: { connectionId, deviceName: response.device_name } };1180-          }1181-1182-          if (response.type === "PAIR_REJECT") {1183-            sync.disconnectFromPeer(connectionId);1184-            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };1185-          }1186-1187-          sync.disconnectFromPeer(connectionId);1188-          return { ok: false, error: "unexpected_response" };1189-        } catch (err) {1190-          if (connectionId) {1191-            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }1192-          }1193-          return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };1194-        }725+        return { ok: false, error: "no_native_host" };1195726       }1196727       case "DEBUG_INJECT_INCOMING_PAIRING": {1197728         // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing@@ -1223,19 +754,7 @@1223754         for (const device of devices) {1224755           await removePairedDevice(device.deviceId);1225756         }1226-        // Native rotation is best-effort: when the native messaging host is1227-        // not yet installed (Phase 9), connectNative throws and we still1228-        // honour the local clear so the UI reflects the reset. The native1229-        // identity rotates the next time the host starts cleanly anyway.1230-        let nativePublicKey: string | null = null;1231-        try {1232-          const { rotateNativeIdentity, disconnectSyncHost } = await import('./sync');1233-          nativePublicKey = await rotateNativeIdentity();1234-          disconnectSyncHost();1235-        } catch {1236-          nativePublicKey = null;1237-        }1238-        return { ok: true, data: { removedDevices: devices.length, nativePublicKey } };757+        return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };1239758       }1240759       case "RECORD_MANUAL_SUMMON": {1241760         const { domain } = msg.payload as { domain: string };@@ -1353,13 +872,8 @@1353872         // Caller (Panic.tsx) handles UI navigation back to onboarding.1354873         vault.lock();1355874         await storageClear();1356-        // Belt-and-suspenders: storage.local.clear() already wipes1357-        // peach_vault_history, but if storage.ts ever routes history to a1358-        // different backend (e.g. Capacitor secure storage on Android) the1359-        // explicit clear keeps the invariant that Panic leaves no recoverable1360-        // vault state behind.1361875         await vault.clearVaultHistory();1362-        if (!Capacitor.isNativePlatform() && chrome.storage.session) {876+        if (chrome.storage.session) {1363877           await chrome.storage.session.clear();1364878         }1365879         return { ok: true };@@ -1401,7 +915,7 @@1401915 1402916 /** Notify all content scripts that the vault just became available. */1403917 function broadcastToContentScripts(message: { type: string }): void {1404-  if (Capacitor.isNativePlatform() || !chrome.tabs?.query) return;918+  if (!chrome.tabs?.query) return;1405919   chrome.tabs.query({}, (tabs) => {1406920     for (const tab of tabs) {1407921       if (tab.id !== undefined) {

view file

src/background/sync-coordinator.ts+0 −116
@@ -238,119 +238,3 @@238238     }239239   });240240 }241-242-/**243- * Build a transport adapter that bridges a native-host connection (via sync.ts)244- * into the SyncSessionTransport interface runBlobSyncSession consumes. The245- * adapter scopes frame/error/disconnect events to a single connection id so246- * concurrent sync sessions on different connections don't cross-talk.247- */248-export interface NativeSyncTransportBindings {249-  sendSyncData: (connectionId: string, data: string) => void;250-  onSyncMessage: (251-    handler: (connectionId: string, data: string) => void,252-  ) => () => void;253-  onError: (254-    handler: (message: string, connectionId?: string) => void,255-  ) => () => void;256-  onDisconnected: (handler: (connectionId?: string) => void) => () => void;257-  disconnectFromPeer: (connectionId: string) => void;258-}259-260-async function loadNativeSyncBindings(): Promise<NativeSyncTransportBindings> {261-  const sync = await import("./sync");262-  return {263-    sendSyncData: sync.sendSyncData,264-    onSyncMessage: sync.onSyncMessage,265-    onError: sync.onError,266-    onDisconnected: sync.onDisconnected,267-    disconnectFromPeer: sync.disconnectFromPeer,268-  };269-}270-271-/**272- * Wait for the next sync frame on a specific connection or reject after273- * `timeoutMs`. Used by the pairing flow to await a single PAIR_ACCEPT /274- * PAIR_REJECT response. Resolves with the decoded SyncMessage; throws on275- * timeout, decode failure, or transport disconnect.276- */277-export async function waitForPairingResponse(278-  connectionId: string,279-  timeoutMs: number,280-  bindings?: NativeSyncTransportBindings,281-): Promise<SyncMessage> {282-  const ready = bindings ?? (await loadNativeSyncBindings());283-  return new Promise<SyncMessage>((resolve, reject) => {284-    let settled = false;285-    const cleanup: Array<() => void> = [];286-    const finish = (action: () => void) => {287-      if (settled) return;288-      settled = true;289-      for (const fn of cleanup) {290-        try {291-          fn();292-        } catch {293-          // unsubscribe must not throw294-        }295-      }296-      action();297-    };298-299-    const handle = setTimeout(300-      () => finish(() => reject(new Error("pairing_response_timeout"))),301-      timeoutMs,302-    );303-    cleanup.push(() => clearTimeout(handle));304-305-    cleanup.push(306-      ready.onSyncMessage((id, frame) => {307-        if (id !== connectionId) return;308-        try {309-          const msg = decodeSyncMessage(frame);310-          finish(() => resolve(msg));311-        } catch (err) {312-          finish(() => reject(err instanceof Error ? err : new Error(String(err))));313-        }314-      }),315-    );316-317-    cleanup.push(318-      ready.onDisconnected((id) => {319-        if (id === undefined || id === connectionId) {320-          finish(() => reject(new Error("pairing_connection_closed")));321-        }322-      }),323-    );324-325-    cleanup.push(326-      ready.onError((message, id) => {327-        if (id === undefined || id === connectionId) {328-          finish(() => reject(new Error(message || "pairing_transport_error")));329-        }330-      }),331-    );332-  });333-}334-335-export async function createNativeSyncTransport(336-  connectionId: string,337-  bindings?: NativeSyncTransportBindings,338-): Promise<SyncSessionTransport> {339-  const ready = bindings ?? (await loadNativeSyncBindings());340-  return {341-    send: (frame) => ready.sendSyncData(connectionId, frame),342-    onFrame: (handler) =>343-      ready.onSyncMessage((id, frame) => {344-        if (id === connectionId) handler(frame);345-      }),346-    onError: (handler) =>347-      ready.onError((message, id) => {348-        if (id === undefined || id === connectionId) handler(message);349-      }),350-    onClose: (handler) =>351-      ready.onDisconnected((id) => {352-        if (id === undefined || id === connectionId) handler();353-      }),354-    close: () => ready.disconnectFromPeer(connectionId),355-  };356-}

view file

src/background/sync-lifecycle.ts+0 −385
@@ -1,385 +0,0 @@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 { addPairedDevice, getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22-import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";23-import {24-  getActivePairingToken,25-  setActivePairingToken,26-  setRecentPairing,27-} from "./pairing-server-state";28-29-const DEVICE_ID_KEY = "peach_device_id";30-const DEVICE_NAME_KEY = "peach_device_name";31-32-let advertisingActive = false;33-let nativeHostConnected = false;34-let incomingSubscriberInstalled = false;35-let peerCacheInstalled = false;36-const peerCache = new Map<string, sync.PeerInfo>();37-const LAST_SYNC_KEY = "peach_last_sync_at";38-const AUTO_SYNC_DEBOUNCE_MS = 60_000;39-40-function ensurePeerCache(): void {41-  if (peerCacheInstalled) return;42-  peerCacheInstalled = true;43-  sync.onPeerDiscovered((peer) => {44-    peerCache.set(peer.x25519PublicKey, peer);45-    void maybeAutoSync(peer);46-  });47-  sync.onPeerLost((deviceId) => {48-    for (const [key, peer] of peerCache) {49-      if (peer.deviceId === deviceId) {50-        peerCache.delete(key);51-        break;52-      }53-    }54-  });55-}56-57-export function getDiscoveredPeerCache(): sync.PeerInfo[] {58-  return Array.from(peerCache.values());59-}60-61-async function maybeAutoSync(peer: sync.PeerInfo): Promise<void> {62-  if (Capacitor.isNativePlatform()) return;63-  try {64-    const paired = await getPairedDevices();65-    if (!paired.some((d) => d.x25519PublicKey === peer.x25519PublicKey)) return;66-    if (!vault.isUnlocked()) return;67-    if (!(await isProUser())) return;68-69-    // 60s debounce — don't hammer a peer that just appeared after a short blip.70-    const lastRaw = await readStorage(LAST_SYNC_KEY);71-    if (lastRaw) {72-      const last = Number.parseInt(lastRaw, 10);73-      if (Number.isFinite(last) && Date.now() - last < AUTO_SYNC_DEBOUNCE_MS) return;74-    }75-76-    const { createNativeSyncTransport, runBlobSyncSession } = await import(77-      "./sync-coordinator"78-    );79-    const { storageGet, storageSet } = await import("../shared/storage");80-81-    const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);82-    const transport = await createNativeSyncTransport(connectionId);83-    await runBlobSyncSession("initiator", transport, {84-      isPro: () => isProUser(),85-      isUnlocked: () => vault.isUnlocked(),86-      getVaultBlobs: async () => ({87-        vault_a: (await storageGet("peach_vault_a")) ?? "",88-        vault_b: (await storageGet("peach_vault_b")) ?? "",89-      }),90-    });91-    await storageSet(LAST_SYNC_KEY, String(Date.now()));92-  } catch (err) {93-    // Auto-sync is silent — user can retry manually from Settings.94-    console.warn("auto-sync failed:", err);95-  }96-}97-98-async function readStorage(key: string): Promise<string | null> {99-  try {100-    const result = await chrome.storage.local.get(key);101-    const value = (result as Record<string, string | undefined>)[key];102-    return typeof value === "string" ? value : null;103-  } catch {104-    return null;105-  }106-}107-108-async function getOrCreateDeviceId(): Promise<string> {109-  try {110-    const stored = await chrome.storage.local.get(DEVICE_ID_KEY);111-    const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];112-    if (typeof value === "string" && value) return value;113-  } catch {114-    // fall through115-  }116-  // base64url-encoded 16-byte random — matches the byte length the native117-  // host expects (see Advertise message decoding).118-  const random = crypto.getRandomValues(new Uint8Array(16));119-  let binary = "";120-  for (const byte of random) binary += String.fromCharCode(byte);121-  const id = btoa(binary)122-    .replace(/\+/g, "-")123-    .replace(/\//g, "_")124-    .replace(/=+$/g, "");125-  try {126-    await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });127-  } catch {128-    // best-effort129-  }130-  return id;131-}132-133-async function getDeviceName(): Promise<string> {134-  try {135-    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);136-    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];137-    if (typeof value === "string" && value.trim()) return value.trim();138-  } catch {139-    // fall through140-  }141-  if (Capacitor.getPlatform() === "android") return "Peach Android";142-  if (Capacitor.getPlatform() === "ios") return "Peach iOS";143-  return "Peach";144-}145-146-/**147- * Reconcile mDNS advertisement with current state. Idempotent.148- */149-export async function syncLifecycleTick(): Promise<void> {150-  // Capacitor (Android) does not run a native messaging host; sync there151-  // happens via the Capacitor bridge which manages its own discovery.152-  if (Capacitor.isNativePlatform()) return;153-154-  if (!nativeHostConnected) {155-    advertisingActive = false;156-    return;157-  }158-159-  let shouldAdvertise = false;160-  try {161-    const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]);162-    shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0;163-  } catch {164-    shouldAdvertise = false;165-  }166-167-  if (shouldAdvertise && !advertisingActive) {168-    try {169-      const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]);170-      await sync.advertise({ deviceId, name });171-      advertisingActive = true;172-    } catch (err) {173-      console.warn("sync advertise failed:", err);174-    }175-  } else if (!shouldAdvertise && advertisingActive) {176-    try {177-      await sync.stopAdvertising();178-    } catch {179-      // best-effort180-    }181-    advertisingActive = false;182-  }183-}184-185-/**186- * Subscribe once to incoming sync connections from the native host. Routes187- * pairing-only connections into the session-stored IncomingPairingRequest the188- * popup banner reads from; data-sync connections are handed off to a189- * runBlobSyncSession in responder mode.190- */191-export function setupIncomingPairingSubscriber(): void {192-  if (incomingSubscriberInstalled) return;193-  incomingSubscriberInstalled = true;194-195-  sync.onIncomingConnection((info) => {196-    void handleIncomingConnection(info);197-  });198-}199-200-async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {201-  if (info.isPairingOnly) {202-    await waitForPairRequest(info);203-    return;204-  }205-  // Trusted data-sync connection — accept and run a responder session.206-  await runResponderSession(info.connectionId);207-}208-209-async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise<void> {210-  const unsubscribe = sync.onSyncMessage((id, frame) => {211-    if (id !== info.connectionId) return;212-    unsubscribe();213-    let publicKeyHex = info.peerPublicKeyHex ?? "";214-    let deviceName = "Unknown device";215-    try {216-      const decoded = decodeSyncMessage(frame);217-      if (decoded.type === "PAIR_REQUEST") {218-        publicKeyHex = decoded.public_key_hex || publicKeyHex;219-        deviceName = decoded.device_name || deviceName;220-      } else {221-        // Unexpected first frame on a pairing-only connection — drop.222-        try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }223-        return;224-      }225-    } catch {226-      try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }227-      return;228-    }229-230-    void completePairRequest(info.connectionId, publicKeyHex, deviceName);231-  });232-233-  // Auto-cleanup if the connection drops before any frame arrives.234-  const offClose = sync.onDisconnected((id) => {235-    if (id === undefined || id === info.connectionId) {236-      try { unsubscribe(); } catch { /* best-effort */ }237-      try { offClose(); } catch { /* best-effort */ }238-    }239-  });240-}241-242-/**243- * Either auto-accept (active 6-digit code session) or queue the banner.244- * The user's act of showing a code IS the consent, so a PAIR_REQUEST that245- * arrives within an active code window does not need a second confirmation.246- */247-async function completePairRequest(248-  connectionId: string,249-  publicKeyHex: string,250-  deviceName: string,251-): Promise<void> {252-  const activeToken = await getActivePairingToken();253-  if (activeToken) {254-    try {255-      const ourKey = await sync.getIdentityPublicKey();256-      const ourName = await getDeviceName();257-      sync.sendSyncData(258-        connectionId,259-        encodeSyncMessage({260-          type: "PAIR_ACCEPT",261-          public_key_hex: ourKey,262-          device_name: ourName,263-        }),264-      );265-      await sync.trustDevice(publicKeyHex, deviceName);266-      try {267-        await addPairedDevice({268-          deviceId: publicKeyHex,269-          displayName: deviceName,270-          x25519PublicKey: publicKeyHex,271-          pairedAt: Date.now(),272-          lastSeenAt: Date.now(),273-        });274-      } catch {275-        // Already paired — fine, idempotent.276-      }277-      await setRecentPairing({278-        deviceName,279-        pairedAt: Date.now(),280-        side: "showing",281-      });282-      // One code = one pairing. Clear the token so a second peer can't283-      // sneak in on the same window.284-      await setActivePairingToken(null);285-      try { sync.stopPairingServer(); } catch { /* best-effort */ }286-      chrome.runtime287-        .sendMessage({ type: "PAIRING_COMPLETED", payload: { deviceName } })288-        .catch(() => {});289-    } catch (err) {290-      console.warn("auto-accept pairing failed:", err);291-    } finally {292-      try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }293-    }294-    return;295-  }296-297-  // No active code — fall back to the legacy banner flow (kept as a no-op298-  // unless something off-flow drops a pairing-only connection on us).299-  const now = Date.now();300-  await setIncomingPairingRequest({301-    deviceName,302-    publicKeyHex,303-    receivedAt: now,304-    expiresAt: now + 60_000,305-    connectionId,306-  });307-  chrome.runtime308-    .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })309-    .catch(() => {});310-}311-312-async function runResponderSession(connectionId: string): Promise<void> {313-  try {314-    const { createNativeSyncTransport, runBlobSyncSession } = await import(315-      "./sync-coordinator"316-    );317-    const { storageGet } = await import("../shared/storage");318-    const transport = await createNativeSyncTransport(connectionId);319-    await runBlobSyncSession("responder", transport, {320-      isPro: () => isProUser(),321-      isUnlocked: () => vault.isUnlocked(),322-      getVaultBlobs: async () => ({323-        vault_a: (await storageGet("peach_vault_a")) ?? "",324-        vault_b: (await storageGet("peach_vault_b")) ?? "",325-      }),326-    });327-  } catch (err) {328-    console.warn("responder sync session failed:", err);329-  }330-}331-332-// ---------------------------------------------------------------------------333-// External-event hooks. The background's lifecycle layer (entry.ts) calls334-// these on relevant transitions so the lifecycle tick is always coherent.335-// ---------------------------------------------------------------------------336-337-export function onNativeHostConnected(): void {338-  nativeHostConnected = true;339-  setupIncomingPairingSubscriber();340-  ensurePeerCache();341-  // Kick off discovery so the cache populates and auto-sync can fire.342-  try {343-    sync.discoverPeers();344-  } catch {345-    // best-effort346-  }347-  void syncLifecycleTick();348-}349-350-export function onNativeHostDisconnected(): void {351-  nativeHostConnected = false;352-  advertisingActive = false;353-}354-355-export async function onVaultUnlocked(): Promise<void> {356-  await syncLifecycleTick();357-}358-359-export async function onVaultLocked(): Promise<void> {360-  // A locked Peach must not be accepting new trust relationships — tear down361-  // any active 6-digit pairing window before reconciling mDNS.362-  try {363-    await setActivePairingToken(null);364-    if (nativeHostConnected) {365-      try { await sync.stopPairingServer(); } catch { /* best-effort */ }366-    }367-  } catch {368-    // best-effort369-  }370-  await syncLifecycleTick();371-}372-373-export async function onProActivated(): Promise<void> {374-  await syncLifecycleTick();375-}376-377-/**378- * Test-only — reset the module-local mutable state so each test starts from a379- * known baseline. Production code never calls this.380- */381-export function __resetForTests(): void {382-  advertisingActive = false;383-  nativeHostConnected = false;384-  incomingSubscriberInstalled = false;385-}

view file at the parent

src/background/sync.ts+0 −553
@@ -1,553 +0,0 @@1-/**2- * GATED — peach-sync native host client.3- *4- * This module mirrors the Rust host protocol but is still not imported by any5- * live background path. Keep `nativeMessaging` out of the manifests until the6- * SYN-001 audit/gate checklist passes and the installer manifest exists.7- */8-9-export const SYNC_NATIVE_HOST_NAME = "com.peachpasswords.sync";10-11-type HostMessage =12-  | { type: "GET_IDENTITY" }13-  | { type: "GET_TRUSTED_DEVICES" }14-  | { type: "TRUST_DEVICE"; public_key_hex: string; device_name: string }15-  | { type: "REMOVE_TRUSTED_DEVICE"; public_key_hex: string }16-  | { type: "ROTATE_IDENTITY" }17-  | {18-      type: "ADVERTISE";19-      x25519_public_key: string;20-      device_id: string;21-      name: string;22-      pairing?: boolean;23-      port?: number;24-    }25-  | { type: "STOP_ADVERTISING" }26-  | { type: "DISCOVER" }27-  | { type: "STOP_DISCOVERY" }28-  | {29-      type: "CONNECT";30-      host: string;31-      port: number;32-      remote_x25519_public_key: string;33-      local_x25519_private_key: string;34-    }35-  | { type: "DISCONNECT"; connection_id: string }36-  | { type: "SYNC_SEND"; connection_id: string; data: string }37-  | { type: "GET_SESSION_KEY"; connection_id: string }38-  | { type: "STATUS" }39-  | { type: "ENTER_PAIRING_MODE" }40-  | { type: "EXIT_PAIRING_MODE" }41-  | { type: "START_PAIRING_SERVER"; token: string; device_id: string; name: string }42-  | { type: "STOP_PAIRING_SERVER" }43-  | { type: "RESOLVE_PEER_BY_CODE"; token: string; timeout_ms?: number };44-45-type HostPeer = {46-  host: string;47-  port: number;48-  ws_port?: number | null;49-  x25519_public_key: string;50-  device_id: string;51-  name: string;52-  pairing?: boolean;53-};54-55-type HostTrustedDevice = {56-  public_key_hex: string;57-  device_name: string;58-  paired_at: number;59-  last_seen: number;60-};61-62-type HostResponse =63-  | { type: "IDENTITY"; public_key_hex: string }64-  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }65-  | { type: "TRUST_DEVICE_OK" }66-  | { type: "REMOVE_TRUSTED_DEVICE_OK" }67-  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }68-  | { type: "ADVERTISING"; port: number; ws_port?: number | null }69-  | { type: "STOP_ADVERTISING_OK" }70-  | { type: "PEER_DISCOVERED"; peer: HostPeer }71-  | { type: "PEER_LOST"; device_id: string }72-  | { type: "STOP_DISCOVERY_OK" }73-  | { type: "CONNECTED"; connection_id: string }74-  | {75-      type: "PEER_CONNECTED";76-      connection_id: string;77-      peer_addr: string;78-      is_pairing_only?: boolean;79-      peer_public_key_hex?: string;80-    }81-  | { type: "DISCONNECTED"; connection_id: string }82-  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }83-  | { type: "SESSION_KEY"; connection_id: string; key: string }84-  | {85-      type: "STATUS";86-      advertising: boolean;87-      tcp_port?: number | null;88-      ws_port?: number | null;89-      connections: string[];90-    }91-  | { type: "ERROR"; connection_id?: string | null; message: string }92-  | { type: "PAIRING_MODE_ENTERED" }93-  | { type: "PAIRING_MODE_EXITED" }94-  | { type: "PAIRING_SERVER_STARTED"; tcp_port: number; ws_port?: number | null }95-  | { type: "STOP_PAIRING_SERVER_OK" }96-  | { type: "PEER_RESOLVED"; peer: HostPeer };97-98-export interface PeerInfo {99-  host: string;100-  port: number;101-  /**102-   * WebSocket port advertised in mDNS `wsport`. Mobile clients (Android via103-   * Capacitor) connect through this; extension/desktop clients ignore it and104-   * use the raw TCP `port` instead.105-   */106-  wsPort?: number;107-  name: string;108-  pairing: boolean;109-  deviceId: string;110-  x25519PublicKey: string;111-}112-113-export interface NativeTrustedDevice {114-  publicKeyHex: string;115-  deviceName: string;116-  pairedAt: number;117-  lastSeen: number;118-}119-120-export interface NativeSyncStatus {121-  advertising: boolean;122-  tcpPort?: number;123-  wsPort?: number;124-  connections: string[];125-}126-127-type ExpectedType = HostResponse["type"];128-type PendingRequest<T extends HostResponse = HostResponse> = {129-  expected: readonly ExpectedType[];130-  resolve: (response: T) => void;131-  reject: (error: Error) => void;132-  timeout: ReturnType<typeof setTimeout>;133-};134-135-let syncPort: chrome.runtime.Port | null = null;136-let pendingRequests: PendingRequest[] = [];137-138-const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];139-const peerLostHandlers: Array<(deviceId: string) => void> = [];140-const connectedHandlers: Array<(connectionId: string) => void> = [];141-export interface IncomingConnectionInfo {142-  connectionId: string;143-  peerAddr: string;144-  /** True when admitted only because pairing mode was active. */145-  isPairingOnly: boolean;146-  /** Hex-encoded x25519 public key the initiator authenticated with. */147-  peerPublicKeyHex?: string;148-}149-150-const incomingConnectionHandlers: Array<(info: IncomingConnectionInfo) => void> = [];151-const disconnectedHandlers: Array<(connectionId?: string) => void> = [];152-const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];153-const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];154-155-function handleHostMessage(response: HostResponse): void {156-  switch (response.type) {157-    case "PEER_DISCOVERED": {158-      const peer = mapPeer(response.peer);159-      for (const handler of peerDiscoveredHandlers) handler(peer);160-      break;161-    }162-    case "PEER_LOST": {163-      for (const handler of peerLostHandlers) handler(response.device_id);164-      break;165-    }166-    case "PEER_CONNECTED": {167-      const info: IncomingConnectionInfo = {168-        connectionId: response.connection_id,169-        peerAddr: response.peer_addr,170-        isPairingOnly: response.is_pairing_only === true,171-        peerPublicKeyHex: response.peer_public_key_hex,172-      };173-      for (const handler of incomingConnectionHandlers) handler(info);174-      break;175-    }176-    case "DISCONNECTED": {177-      for (const handler of disconnectedHandlers) handler(response.connection_id);178-      break;179-    }180-    case "SYNC_RECEIVED": {181-      for (const handler of syncMessageHandlers) {182-        handler(response.connection_id, response.data);183-      }184-      break;185-    }186-    case "ERROR": {187-      rejectPending(response.message, response.connection_id ?? undefined);188-      for (const handler of errorHandlers) {189-        handler(response.message, response.connection_id ?? undefined);190-      }191-      break;192-    }193-    default:194-      resolveExpected(response);195-      if (response.type === "CONNECTED") {196-        for (const handler of connectedHandlers) handler(response.connection_id);197-      }198-      break;199-  }200-}201-202-function resolveExpected(response: HostResponse): void {203-  const index = pendingRequests.findIndex((pending) =>204-    pending.expected.includes(response.type)205-  );206-  if (index === -1) return;207-208-  const [pending] = pendingRequests.splice(index, 1);209-  clearTimeout(pending.timeout);210-  pending.resolve(response);211-}212-213-function rejectPending(message: string, connectionId?: string): void {214-  const index = pendingRequests.findIndex((pending) =>215-    connectionId ? pending.expected.includes("CONNECTED") : true216-  );217-  if (index === -1) return;218-219-  const [pending] = pendingRequests.splice(index, 1);220-  clearTimeout(pending.timeout);221-  pending.reject(new Error(message));222-}223-224-function rejectAllPending(message: string): void {225-  const pending = pendingRequests;226-  pendingRequests = [];227-  for (const request of pending) {228-    clearTimeout(request.timeout);229-    request.reject(new Error(message));230-  }231-}232-233-function sendToHost(msg: HostMessage): void {234-  if (!syncPort) throw new Error("sync native host is not connected");235-  syncPort.postMessage(msg);236-}237-238-function requestFromHost<T extends HostResponse>(239-  msg: HostMessage,240-  expected: readonly ExpectedType[],241-  timeoutMs = 5000242-): Promise<T> {243-  if (!syncPort) connectSyncHost();244-245-  return new Promise<T>((resolve, reject) => {246-    const pending: PendingRequest<T> = {247-      expected,248-      resolve,249-      reject,250-      timeout: setTimeout(() => {251-        pendingRequests = pendingRequests.filter((entry) => entry !== pending);252-        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));253-      }, timeoutMs),254-    };255-256-    pendingRequests.push(pending as PendingRequest);257-    try {258-      sendToHost(msg);259-    } catch (err) {260-      pendingRequests = pendingRequests.filter((entry) => entry !== pending);261-      clearTimeout(pending.timeout);262-      reject(err instanceof Error ? err : new Error(String(err)));263-    }264-  });265-}266-267-export function connectSyncHost(): void {268-  if (syncPort) return;269-  if (!chrome.runtime.connectNative) {270-    throw new Error("native messaging is not available in this browser context");271-  }272-273-  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);274-  syncPort.onMessage.addListener(handleHostMessage);275-  syncPort.onDisconnect.addListener(() => {276-    syncPort = null;277-    rejectAllPending("sync native host disconnected");278-    for (const handler of disconnectedHandlers) handler();279-  });280-}281-282-export function disconnectSyncHost(): void {283-  const port = syncPort;284-  syncPort = null;285-  rejectAllPending("sync native host disconnected");286-  port?.disconnect();287-}288-289-export function isHostConnected(): boolean {290-  return syncPort !== null;291-}292-293-export async function getNativeIdentity(): Promise<string> {294-  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(295-    { type: "GET_IDENTITY" },296-    ["IDENTITY"]297-  );298-  return response.public_key_hex;299-}300-301-export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {302-  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(303-    { type: "GET_TRUSTED_DEVICES" },304-    ["TRUSTED_DEVICES"]305-  );306-  return response.devices.map(mapTrustedDevice);307-}308-309-export async function trustDevice(310-  publicKeyHex: string,311-  deviceName: string312-): Promise<void> {313-  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [314-    "TRUST_DEVICE_OK",315-  ]);316-}317-318-export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {319-  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [320-    "REMOVE_TRUSTED_DEVICE_OK",321-  ]);322-}323-324-export async function rotateNativeIdentity(): Promise<string> {325-  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(326-    { type: "ROTATE_IDENTITY" },327-    ["ROTATE_IDENTITY_OK"]328-  );329-  return response.public_key_hex;330-}331-332-export async function advertise(options: {333-  deviceId: string;334-  name: string;335-  x25519PublicKey?: string;336-  pairing?: boolean;337-  port?: number;338-}): Promise<number> {339-  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(340-    {341-      type: "ADVERTISE",342-      x25519_public_key: options.x25519PublicKey ?? "",343-      device_id: options.deviceId,344-      name: options.name,345-      pairing: options.pairing ?? false,346-      port: options.port ?? 0,347-    },348-    ["ADVERTISING"]349-  );350-  return response.port;351-}352-353-export async function stopAdvertising(): Promise<void> {354-  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);355-}356-357-export function discoverPeers(): void {358-  sendToHost({ type: "DISCOVER" });359-}360-361-export async function stopDiscovery(): Promise<void> {362-  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);363-}364-365-export async function connectToPeer(366-  host: string,367-  port: number,368-  remoteX25519PublicKey: string369-): Promise<string> {370-  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(371-    {372-      type: "CONNECT",373-      host,374-      port,375-      remote_x25519_public_key: remoteX25519PublicKey,376-      // The Rust host owns the private identity now and ignores this legacy377-      // field, so the extension must never send private key material here.378-      local_x25519_private_key: "",379-    },380-    ["CONNECTED"]381-  );382-  return response.connection_id;383-}384-385-export function disconnectFromPeer(connectionId: string): void {386-  sendToHost({ type: "DISCONNECT", connection_id: connectionId });387-}388-389-export function sendSyncData(connectionId: string, data: string): void {390-  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });391-}392-393-export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {394-  const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(395-    { type: "GET_SESSION_KEY", connection_id: connectionId },396-    ["SESSION_KEY"]397-  );398-  if (response.connection_id !== connectionId) {399-    throw new Error("sync native host returned session key for unexpected connection");400-  }401-  const decoded = decodeBase64Url(response.key);402-  if (decoded.length !== 32) {403-    throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);404-  }405-  return decoded;406-}407-408-function decodeBase64Url(value: string): Uint8Array {409-  const padded = value410-    .replace(/-/g, "+")411-    .replace(/_/g, "/")412-    .padEnd(Math.ceil(value.length / 4) * 4, "=");413-  const binary = atob(padded);414-  const out = new Uint8Array(binary.length);415-  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);416-  return out;417-}418-419-export async function enterPairingMode(): Promise<void> {420-  await requestFromHost({ type: "ENTER_PAIRING_MODE" }, ["PAIRING_MODE_ENTERED"]);421-}422-423-export async function exitPairingMode(): Promise<void> {424-  await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);425-}426-427-export interface PairingServerInfo {428-  tcpPort: number;429-  wsPort?: number;430-}431-432-export async function startPairingServer(options: {433-  token: string;434-  deviceId: string;435-  name: string;436-}): Promise<PairingServerInfo> {437-  const response = await requestFromHost<Extract<HostResponse, { type: "PAIRING_SERVER_STARTED" }>>(438-    {439-      type: "START_PAIRING_SERVER",440-      token: options.token,441-      device_id: options.deviceId,442-      name: options.name,443-    },444-    ["PAIRING_SERVER_STARTED"],445-    15_000,446-  );447-  return {448-    tcpPort: response.tcp_port,449-    wsPort: response.ws_port ?? undefined,450-  };451-}452-453-export async function stopPairingServer(): Promise<void> {454-  await requestFromHost({ type: "STOP_PAIRING_SERVER" }, ["STOP_PAIRING_SERVER_OK"]);455-}456-457-export async function resolvePeerByCode(458-  token: string,459-  timeoutMs = 8_000,460-): Promise<PeerInfo> {461-  const response = await requestFromHost<Extract<HostResponse, { type: "PEER_RESOLVED" }>>(462-    { type: "RESOLVE_PEER_BY_CODE", token, timeout_ms: timeoutMs },463-    ["PEER_RESOLVED"],464-    timeoutMs + 2_000,465-  );466-  return mapPeer(response.peer);467-}468-469-/**470- * The native host's static x25519 public key (this device's sync identity).471- * Initiators include this in PAIR_REQUEST so the responder can record us in472- * its trust store after the user accepts; responders do likewise in473- * PAIR_ACCEPT.474- */475-export async function getIdentityPublicKey(): Promise<string> {476-  return getNativeIdentity();477-}478-479-export async function getNativeStatus(): Promise<NativeSyncStatus> {480-  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(481-    { type: "STATUS" },482-    ["STATUS"]483-  );484-  return {485-    advertising: response.advertising,486-    tcpPort: response.tcp_port ?? undefined,487-    wsPort: response.ws_port ?? undefined,488-    connections: response.connections,489-  };490-}491-492-export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {493-  return subscribe(peerDiscoveredHandlers, handler);494-}495-496-export function onPeerLost(handler: (deviceId: string) => void): () => void {497-  return subscribe(peerLostHandlers, handler);498-}499-500-export function onConnected(handler: (connectionId: string) => void): () => void {501-  return subscribe(connectedHandlers, handler);502-}503-504-export function onIncomingConnection(505-  handler: (info: IncomingConnectionInfo) => void506-): () => void {507-  return subscribe(incomingConnectionHandlers, handler);508-}509-510-export function onDisconnected(handler: (connectionId?: string) => void): () => void {511-  return subscribe(disconnectedHandlers, handler);512-}513-514-export function onSyncMessage(515-  handler: (connectionId: string, data: string) => void516-): () => void {517-  return subscribe(syncMessageHandlers, handler);518-}519-520-export function onError(521-  handler: (error: string, connectionId?: string) => void522-): () => void {523-  return subscribe(errorHandlers, handler);524-}525-526-function subscribe<T>(handlers: T[], handler: T): () => void {527-  handlers.push(handler);528-  return () => {529-    const index = handlers.indexOf(handler);530-    if (index >= 0) handlers.splice(index, 1);531-  };532-}533-534-function mapPeer(peer: HostPeer): PeerInfo {535-  return {536-    host: peer.host,537-    port: peer.port,538-    wsPort: peer.ws_port ?? undefined,539-    name: peer.name,540-    pairing: peer.pairing ?? false,541-    deviceId: peer.device_id,542-    x25519PublicKey: peer.x25519_public_key,543-  };544-}545-546-function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {547-  return {548-    publicKeyHex: device.public_key_hex,549-    deviceName: device.device_name,550-    pairedAt: device.paired_at,551-    lastSeen: device.last_seen,552-  };553-}

view file at the parent

src/shared/sync-bridge.ts+5 −71
@@ -1,21 +1,15 @@11 /**2- * Sync transport selector. The desktop extension talks to the Rust native3- * messaging host over stdio (`src/background/sync.ts`). Capacitor (Android)4- * cannot speak native messaging or open raw TCP sockets from the WebView,5- * so it discovers peers via NsdManager (the Kotlin `Sync` plugin) and6- * connects through the WebView's WebSocket API to the desktop host's7- * `wsport`.2+ * WebSocket sync bridge (Capacitor / Android).83  *9- * `getSyncBridge()` returns one of two implementations of `SyncBridge`:10- *   - `NativeMessagingSyncBridge` — wraps `src/background/sync.ts`11- *   - `CapacitorSyncBridge` — wraps the `Sync` plugin and a WebSocket4+ * Discovers peers via NsdManager (the Kotlin `Sync` plugin) and5+ * connects through the WebView's WebSocket API to the peer's `wsport`.126  *137  * The shape is intentionally narrow: peer discovery + a single bidirectional148  * encrypted-frame channel keyed by connection id. Higher layers159  * (`sync-coordinator.ts`) compose `SyncSessionTransport` on top.1610  */1711 18-import { Capacitor, registerPlugin } from "@capacitor/core";12+import { registerPlugin } from "@capacitor/core";1913 import type { PluginListenerHandle } from "@capacitor/core";2014 import type { DiscoveredPeer } from "./sync-types";2115 import type { SyncSessionResult } from "../background/sync-coordinator";@@ -141,71 +135,11 @@141135   }142136 }143137 144-class NativeMessagingSyncBridge implements SyncBridge {145-  private peerDiscoveredHandlers: Array<(peer: SyncBridgePeer) => void> = [];146-  private peerLostHandlers: Array<(deviceId: string) => void> = [];147-  private unsubscribers: Array<() => void> = [];148-149-  async startDiscovery(): Promise<void> {150-    const sync = await import("../background/sync");151-    sync.connectSyncHost();152-    const offDiscovered = sync.onPeerDiscovered((peer) => {153-      for (const h of this.peerDiscoveredHandlers) {154-        h({155-          deviceId: peer.deviceId,156-          deviceName: peer.name,157-          host: peer.host,158-          port: peer.port,159-          wsPort: peer.wsPort,160-          x25519PublicKey: peer.x25519PublicKey,161-          pairing: peer.pairing,162-        });163-      }164-    });165-    const offLost = sync.onPeerLost((deviceId) => {166-      for (const h of this.peerLostHandlers) h(deviceId);167-    });168-    this.unsubscribers.push(offDiscovered, offLost);169-    sync.discoverPeers();170-  }171-172-  async stopDiscovery(): Promise<void> {173-    for (const off of this.unsubscribers) off();174-    this.unsubscribers = [];175-    try {176-      const sync = await import("../background/sync");177-      if (sync.isHostConnected()) {178-        await sync.stopDiscovery();179-      }180-    } catch {181-      // best-effort182-    }183-  }184-185-  onPeerDiscovered(handler: (peer: SyncBridgePeer) => void): () => void {186-    this.peerDiscoveredHandlers.push(handler);187-    return () => {188-      const i = this.peerDiscoveredHandlers.indexOf(handler);189-      if (i >= 0) this.peerDiscoveredHandlers.splice(i, 1);190-    };191-  }192-193-  onPeerLost(handler: (deviceId: string) => void): () => void {194-    this.peerLostHandlers.push(handler);195-    return () => {196-      const i = this.peerLostHandlers.indexOf(handler);197-      if (i >= 0) this.peerLostHandlers.splice(i, 1);198-    };199-  }200-}201-202138 let cached: SyncBridge | null = null;203139 204140 export function getSyncBridge(): SyncBridge {205141   if (cached) return cached;206-  cached = Capacitor.isNativePlatform()207-    ? new CapacitorSyncBridge()208-    : new NativeMessagingSyncBridge();142+  cached = new CapacitorSyncBridge();209143   return cached;210144 }211145 

view file