12345678910111213import * as vault from "./vault";14import { assertSyncAllowed, browserGenerationIo } from "./sync-generation";1516import {17 getLastSyncAt,18 getLastSyncResult,19 listPairedDevices,20 markLastSyncedAt,21 setLastSyncAt,22 setLastSyncResult,23} from "./paired-devices";24import type { PairedDevice } from "../shared/sync-types";25import {26 isConnectionClosedError,27 isSyncFailure,28 type SyncOutcome,29} from "../shared/sync-outcome";3031323334353637383940export type SyncTriggerReason =41 | "alarm"42 | "startup"43 | "device_list_changed"44 | "vault_change"45 | "foreground"46 | "manual";4748export type SyncRunStatus = "idle" | "syncing" | "error";4950export interface SyncState {51 status: SyncRunStatus;52 reason?: SyncTriggerReason;53 54 lastSyncAt?: number;55 entriesReceived?: number;56 entriesSent?: number;57 error?: string;58 outcome?: SyncOutcome;59 60 detail?: string;61 finishedAt?: number;62}6364export type SyncRunResult =65 | { ok: true; entriesReceived: number; entriesSent: number }66 | { ok: false; error: string };6768export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;69export type SelectSyncDialTargets = (70 devices: PairedDevice[],71) => Promise<PairedDevice[]>;7273interface PeachSyncTriggerState {74 syncInFlight: boolean;75 lastState: SyncState;76 runner: RunSyncWithDevice | null;77 selectDialTargets: SelectSyncDialTargets | null;78}7980function getSyncTriggerState(): PeachSyncTriggerState {81 const g = globalThis as typeof globalThis & {82 __peachSyncTriggerState?: PeachSyncTriggerState;83 };84 if (!g.__peachSyncTriggerState) {85 g.__peachSyncTriggerState = {86 syncInFlight: false,87 lastState: { status: "idle" },88 runner: null,89 selectDialTargets: null,90 };91 }92 return g.__peachSyncTriggerState;93}9495export function setSyncRunner(fn: RunSyncWithDevice | null): void {96 getSyncTriggerState().runner = fn;97}9899export function setSyncDialTargetSelector(100 fn: SelectSyncDialTargets | null,101): void {102 getSyncTriggerState().selectDialTargets = fn;103}104105export function _resetSyncTriggerForTest(): void {106 const state = getSyncTriggerState();107 state.syncInFlight = false;108 state.lastState = { status: "idle" };109 state.runner = null;110 state.selectDialTargets = null;111}112113export function getSyncState(): SyncState {114 return getSyncTriggerState().lastState;115}116117118119120121122123export async function hydratePersistedSyncState(): Promise<void> {124 const state = getSyncTriggerState();125 if (state.lastState.lastSyncAt === undefined) {126 const persisted = await getLastSyncAt();127 if (typeof persisted === "number") {128 state.lastState = { ...state.lastState, lastSyncAt: persisted };129 }130 }131 132 133 134 if (state.lastState.outcome === undefined) {135 const last = await getLastSyncResult();136 if (last) {137 state.lastState = {138 ...state.lastState,139 outcome: last.outcome,140 detail: last.detail,141 finishedAt: last.at,142 entriesReceived: last.entriesReceived,143 entriesSent: last.entriesSent,144 };145 }146 }147}148export async function triggerSyncIfReachable(opts: {149 reason: SyncTriggerReason;150 force?: boolean;151}): Promise<void> {152 if (vault.isDecoyMode()) return;153 const state = getSyncTriggerState();154 155 156 157 if (state.syncInFlight && !opts.force) {158 159 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);160 return;161 }162 state.syncInFlight = true;163164 try {165 166 167 168 const decision = await assertSyncAllowed(browserGenerationIo);169 if (decision !== "allowed") {170 finish({ outcome: decision });171 return;172 }173 const devices = await listPairedDevices();174 const routeCapable = devices.filter(175 (d) =>176 (177 d.deviceType === "extension" ||178 d.deviceType === "android" ||179 d.deviceType === "ios"180 ) &&181 (d.trustSource === "direct" || d.trustSource === "vouched"),182 );183 184 185 186 const hosts = state.selectDialTargets187 ? await state.selectDialTargets(routeCapable)188 : routeCapable.filter(189 (d) => d.deviceType === "android" || d.deviceType === "ios",190 );191 if (devices.length === 0) {192 finish({ outcome: "no_paired_devices" });193 return;194 }195 if (hosts.length === 0) {196 if (routeCapable.length > 0 && state.selectDialTargets) {197 finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });198 return;199 }200 201 202 finish({ outcome: "no_eligible_host", detail: inventory(devices) });203 return;204 }205 if (!state.runner) {206 finish({ outcome: "runner_unavailable" });207 return;208 }209210 setState({211 status: "syncing",212 reason: opts.reason,213 error: undefined,214 outcome: undefined,215 detail: undefined,216 });217 log(218 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,219 );220 await runSyncRounds(hosts);221 } finally {222 state.syncInFlight = false;223 }224}225226async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {227 const state = getSyncTriggerState();228 if (!state.runner) {229 finish({ outcome: "runner_unavailable" });230 return;231 }232233 let entriesReceived = 0;234 let entriesSent = 0;235 let anySucceeded = false;236 let firstError: string | undefined;237238 for (const host of hosts) {239 try {240 const result = await state.runner(host);241 if (!result.ok) {242 log(`host ${describeHost(host)} refused: ${result.error}`);243 if (firstError === undefined) firstError = result.error;244 continue;245 }246 entriesReceived += result.entriesReceived;247 entriesSent += result.entriesSent;248 if (vault.isDecoyMode()) return;249 await markLastSyncedAt(host.deviceId, Date.now());250 if (vault.isDecoyMode()) return;251 anySucceeded = true;252 } catch (err) {253 const message = (err as Error)?.message ?? String(err);254 log(`host ${describeHost(host)} threw: ${message}`);255 if (firstError === undefined) firstError = message;256 }257 }258259 if (vault.isDecoyMode()) return;260 if (anySucceeded) {261 const now = Date.now();262 finish({263 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",264 265 266 detail: firstError,267 entriesReceived,268 entriesSent,269 });270 271 272 void Promise.resolve()273 .then(() => {274 if (vault.isDecoyMode()) return;275 return setLastSyncAt(now);276 })277 .catch(() => undefined);278 } else {279 const error = firstError ?? "no_host_reachable";280 finish({ outcome: classifySyncError(error), detail: error });281 }282}283284285286287288289function classifySyncError(error: string): SyncOutcome {290 if (error.includes("VAULT_LOCKED")) return "vault_locked";291 if (error.includes("sync_disabled")) return "sync_disabled";292 if (error.includes("private_relay_unset")) return "private_relay_unset";293 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";294 if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";295 if (error.includes("sync_timeout")) return "sync_timeout";296 297 298 299 300 301 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {302 return "connection_lost";303 }304 if (error.startsWith("relay_sync_failed")) {305 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)306 ? "handshake_failed"307 : "relay_unreachable";308 }309 return "run_failed";310}311312interface SyncFinish {313 outcome: SyncOutcome;314 detail?: string;315 entriesReceived?: number;316 entriesSent?: number;317}318319320321322323324function finish(result: SyncFinish): void {325 const now = Date.now();326 const failed = isSyncFailure(result.outcome);327 const entriesReceived = result.entriesReceived ?? 0;328 const entriesSent = result.entriesSent ?? 0;329 setState({330 status: failed ? "error" : "idle",331 outcome: result.outcome,332 detail: result.detail,333 error: failed ? result.detail ?? result.outcome : undefined,334 finishedAt: now,335 entriesReceived,336 entriesSent,337 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"338 ? { lastSyncAt: now }339 : {}),340 });341 log(342 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +343 (entriesReceived + entriesSent > 0344 ? ` (sent ${entriesSent}, received ${entriesReceived})`345 : ""),346 );347 void Promise.resolve()348 .then(() => {349 if (vault.isDecoyMode()) return;350 return setLastSyncResult({351 outcome: result.outcome,352 detail: result.detail,353 at: now,354 entriesReceived,355 entriesSent,356 });357 })358 .catch(() => undefined);359}360361362function inventory(devices: PairedDevice[]): string {363 return devices.map(describeHost).join("; ");364}365366function describeHost(d: PairedDevice): string {367 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;368}369370371372373374function log(message: string): void {375 console.info(`[Peach sync] ${message}`);376}377378function setState(patch: Partial<SyncState>): void {379 const state = getSyncTriggerState();380 state.lastState = { ...state.lastState, ...patch };381 broadcast(state.lastState);382}383384function broadcast(state: SyncState): void {385 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;386 try {387 const maybeThenable = chrome.runtime.sendMessage({388 type: "SYNC_STATE_CHANGED",389 payload: state,390 });391 if (392 maybeThenable &&393 typeof (maybeThenable as Promise<unknown>).catch === "function"394 ) {395 (maybeThenable as Promise<unknown>).catch(() => {396 397 });398 }399 } catch {400 401 }402}