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
↓ peach-browser-31184bea.tar.gzatlas at this save
files changed7 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 −71src/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 scriptssrc/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();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) {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-}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-}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-}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