1234567891011121314151617import { Capacitor } from "@capacitor/core";18import * as sync from "./sync";19import { isProUser } from "./license";20import * as vault from "./vault";21import { getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22import { decodeSyncMessage } from "../shared/sync-wire";2324const DEVICE_ID_KEY = "peach_device_id";25const DEVICE_NAME_KEY = "peach_device_name";2627let advertisingActive = false;28let nativeHostConnected = false;29let incomingSubscriberInstalled = false;30let peerCacheInstalled = false;31const peerCache = new Map<string, sync.PeerInfo>();32const LAST_SYNC_KEY = "peach_last_sync_at";33const AUTO_SYNC_DEBOUNCE_MS = 60_000;3435function ensurePeerCache(): void {36 if (peerCacheInstalled) return;37 peerCacheInstalled = true;38 sync.onPeerDiscovered((peer) => {39 peerCache.set(peer.x25519PublicKey, peer);40 void maybeAutoSync(peer);41 });42 sync.onPeerLost((deviceId) => {43 for (const [key, peer] of peerCache) {44 if (peer.deviceId === deviceId) {45 peerCache.delete(key);46 break;47 }48 }49 });50}5152export function getDiscoveredPeerCache(): sync.PeerInfo[] {53 return Array.from(peerCache.values());54}5556async function maybeAutoSync(peer: sync.PeerInfo): Promise<void> {57 if (Capacitor.isNativePlatform()) return;58 try {59 const paired = await getPairedDevices();60 if (!paired.some((d) => d.x25519PublicKey === peer.x25519PublicKey)) return;61 if (!vault.isUnlocked()) return;62 if (!(await isProUser())) return;6364 65 const lastRaw = await readStorage(LAST_SYNC_KEY);66 if (lastRaw) {67 const last = Number.parseInt(lastRaw, 10);68 if (Number.isFinite(last) && Date.now() - last < AUTO_SYNC_DEBOUNCE_MS) return;69 }7071 const { createNativeSyncTransport, runBlobSyncSession } = await import(72 "./sync-coordinator"73 );74 const { storageGet, storageSet } = await import("../shared/storage");7576 const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);77 const transport = await createNativeSyncTransport(connectionId);78 await runBlobSyncSession("initiator", transport, {79 isPro: () => isProUser(),80 isUnlocked: () => vault.isUnlocked(),81 getVaultBlobs: async () => ({82 vault_a: (await storageGet("peach_vault_a")) ?? "",83 vault_b: (await storageGet("peach_vault_b")) ?? "",84 }),85 });86 await storageSet(LAST_SYNC_KEY, String(Date.now()));87 } catch (err) {88 89 console.warn("auto-sync failed:", err);90 }91}9293async function readStorage(key: string): Promise<string | null> {94 try {95 const result = await chrome.storage.local.get(key);96 const value = (result as Record<string, string | undefined>)[key];97 return typeof value === "string" ? value : null;98 } catch {99 return null;100 }101}102103async function getOrCreateDeviceId(): Promise<string> {104 try {105 const stored = await chrome.storage.local.get(DEVICE_ID_KEY);106 const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];107 if (typeof value === "string" && value) return value;108 } catch {109 110 }111 112 113 const random = crypto.getRandomValues(new Uint8Array(16));114 let binary = "";115 for (const byte of random) binary += String.fromCharCode(byte);116 const id = btoa(binary)117 .replace(/\+/g, "-")118 .replace(/\//g, "_")119 .replace(/=+$/g, "");120 try {121 await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });122 } catch {123 124 }125 return id;126}127128async function getDeviceName(): Promise<string> {129 try {130 const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);131 const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];132 if (typeof value === "string" && value.trim()) return value.trim();133 } catch {134 135 }136 if (Capacitor.getPlatform() === "android") return "Peach Android";137 if (Capacitor.getPlatform() === "ios") return "Peach iOS";138 return "Peach";139}140141142143144export async function syncLifecycleTick(): Promise<void> {145 146 147 if (Capacitor.isNativePlatform()) return;148149 if (!nativeHostConnected) {150 advertisingActive = false;151 return;152 }153154 let shouldAdvertise = false;155 try {156 const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]);157 shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0;158 } catch {159 shouldAdvertise = false;160 }161162 if (shouldAdvertise && !advertisingActive) {163 try {164 const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]);165 await sync.advertise({ deviceId, name });166 advertisingActive = true;167 } catch (err) {168 console.warn("sync advertise failed:", err);169 }170 } else if (!shouldAdvertise && advertisingActive) {171 try {172 await sync.stopAdvertising();173 } catch {174 175 }176 advertisingActive = false;177 }178}179180181182183184185186export function setupIncomingPairingSubscriber(): void {187 if (incomingSubscriberInstalled) return;188 incomingSubscriberInstalled = true;189190 sync.onIncomingConnection((info) => {191 void handleIncomingConnection(info);192 });193}194195async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {196 if (info.isPairingOnly) {197 await waitForPairRequest(info);198 return;199 }200 201 await runResponderSession(info.connectionId);202}203204async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise<void> {205 const unsubscribe = sync.onSyncMessage((id, frame) => {206 if (id !== info.connectionId) return;207 unsubscribe();208 let publicKeyHex = info.peerPublicKeyHex ?? "";209 let deviceName = "Unknown device";210 try {211 const decoded = decodeSyncMessage(frame);212 if (decoded.type === "PAIR_REQUEST") {213 publicKeyHex = decoded.public_key_hex || publicKeyHex;214 deviceName = decoded.device_name || deviceName;215 } else {216 217 try { sync.disconnectFromPeer(info.connectionId); } catch { }218 return;219 }220 } catch {221 try { sync.disconnectFromPeer(info.connectionId); } catch { }222 return;223 }224225 const now = Date.now();226 void setIncomingPairingRequest({227 deviceName,228 publicKeyHex,229 receivedAt: now,230 expiresAt: now + 60_000,231 connectionId: info.connectionId,232 });233234 235 236 chrome.runtime237 .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })238 .catch(() => {});239 });240241 242 const offClose = sync.onDisconnected((id) => {243 if (id === undefined || id === info.connectionId) {244 try { unsubscribe(); } catch { }245 try { offClose(); } catch { }246 }247 });248}249250async function runResponderSession(connectionId: string): Promise<void> {251 try {252 const { createNativeSyncTransport, runBlobSyncSession } = await import(253 "./sync-coordinator"254 );255 const { storageGet } = await import("../shared/storage");256 const transport = await createNativeSyncTransport(connectionId);257 await runBlobSyncSession("responder", transport, {258 isPro: () => isProUser(),259 isUnlocked: () => vault.isUnlocked(),260 getVaultBlobs: async () => ({261 vault_a: (await storageGet("peach_vault_a")) ?? "",262 vault_b: (await storageGet("peach_vault_b")) ?? "",263 }),264 });265 } catch (err) {266 console.warn("responder sync session failed:", err);267 }268}269270271272273274275export function onNativeHostConnected(): void {276 nativeHostConnected = true;277 setupIncomingPairingSubscriber();278 ensurePeerCache();279 280 try {281 sync.discoverPeers();282 } catch {283 284 }285 void syncLifecycleTick();286}287288export function onNativeHostDisconnected(): void {289 nativeHostConnected = false;290 advertisingActive = false;291}292293export async function onVaultUnlocked(): Promise<void> {294 await syncLifecycleTick();295}296297export async function onVaultLocked(): Promise<void> {298 await syncLifecycleTick();299}300301export async function onProActivated(): Promise<void> {302 await syncLifecycleTick();303}304305306307308309export function __resetForTests(): void {310 advertisingActive = false;311 nativeHostConnected = false;312 incomingSubscriberInstalled = false;313}