1234567891011121314151617import { Capacitor } from "@capacitor/core";18import * as sync from "./sync";19import { isProUser } from "./license";20import * as vault from "./vault";21import { addPairedDevice, getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";23import {24 getActivePairingToken,25 setActivePairingToken,26 setRecentPairing,27} from "./pairing-server-state";2829const DEVICE_ID_KEY = "peach_device_id";30const DEVICE_NAME_KEY = "peach_device_name";3132let advertisingActive = false;33let nativeHostConnected = false;34let incomingSubscriberInstalled = false;35let peerCacheInstalled = false;36const peerCache = new Map<string, sync.PeerInfo>();37const LAST_SYNC_KEY = "peach_last_sync_at";38const AUTO_SYNC_DEBOUNCE_MS = 60_000;3940function 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}5657export function getDiscoveredPeerCache(): sync.PeerInfo[] {58 return Array.from(peerCache.values());59}6061async 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;6869 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 }7576 const { createNativeSyncTransport, runBlobSyncSession } = await import(77 "./sync-coordinator"78 );79 const { storageGet, storageSet } = await import("../shared/storage");8081 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 94 console.warn("auto-sync failed:", err);95 }96}9798async 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}107108async 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 115 }116 117 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 129 }130 return id;131}132133async 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 140 }141 if (Capacitor.getPlatform() === "android") return "Peach Android";142 if (Capacitor.getPlatform() === "ios") return "Peach iOS";143 return "Peach";144}145146147148149export async function syncLifecycleTick(): Promise<void> {150 151 152 if (Capacitor.isNativePlatform()) return;153154 if (!nativeHostConnected) {155 advertisingActive = false;156 return;157 }158159 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 }166167 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 180 }181 advertisingActive = false;182 }183}184185186187188189190191export function setupIncomingPairingSubscriber(): void {192 if (incomingSubscriberInstalled) return;193 incomingSubscriberInstalled = true;194195 sync.onIncomingConnection((info) => {196 void handleIncomingConnection(info);197 });198}199200async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {201 if (info.isPairingOnly) {202 await waitForPairRequest(info);203 return;204 }205 206 await runResponderSession(info.connectionId);207}208209async 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 222 try { sync.disconnectFromPeer(info.connectionId); } catch { }223 return;224 }225 } catch {226 try { sync.disconnectFromPeer(info.connectionId); } catch { }227 return;228 }229230 void completePairRequest(info.connectionId, publicKeyHex, deviceName);231 });232233 234 const offClose = sync.onDisconnected((id) => {235 if (id === undefined || id === info.connectionId) {236 try { unsubscribe(); } catch { }237 try { offClose(); } catch { }238 }239 });240}241242243244245246247async 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 276 }277 await setRecentPairing({278 deviceName,279 pairedAt: Date.now(),280 side: "showing",281 });282 283 284 await setActivePairingToken(null);285 try { sync.stopPairingServer(); } catch { }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 { }293 }294 return;295 }296297 298 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}311312async 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}331332333334335336337export function onNativeHostConnected(): void {338 nativeHostConnected = true;339 setupIncomingPairingSubscriber();340 ensurePeerCache();341 342 try {343 sync.discoverPeers();344 } catch {345 346 }347 void syncLifecycleTick();348}349350export function onNativeHostDisconnected(): void {351 nativeHostConnected = false;352 advertisingActive = false;353}354355export async function onVaultUnlocked(): Promise<void> {356 await syncLifecycleTick();357}358359export async function onVaultLocked(): Promise<void> {360 361 362 try {363 await setActivePairingToken(null);364 if (nativeHostConnected) {365 try { await sync.stopPairingServer(); } catch { }366 }367 } catch {368 369 }370 await syncLifecycleTick();371}372373export async function onProActivated(): Promise<void> {374 await syncLifecycleTick();375}376377378379380381export function __resetForTests(): void {382 advertisingActive = false;383 nativeHostConnected = false;384 incomingSubscriberInstalled = false;385}