12345678910111213import {14 getLastSyncAt,15 listPairedDevices,16 markLastSyncedAt,17 setLastSyncAt,18 setLastSyncResult,19} from "./paired-devices";20import type { PairedDevice } from "../shared/sync-types";2122export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";2324export type SyncRunStatus = "idle" | "syncing" | "error";2526export interface SyncState {27 status: SyncRunStatus;28 reason?: SyncTriggerReason;29 lastSyncAt?: number;30 entriesReceived?: number;31 entriesSent?: number;32 error?: string;33}3435export interface SyncRunResult {36 entriesReceived: number;37 entriesSent: number;38}3940export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4142let syncInFlight = false;43let lastState: SyncState = { status: "idle" };44let runner: RunSyncWithDevice | null = null;4546export function setSyncRunner(fn: RunSyncWithDevice | null): void {47 runner = fn;48}4950export function _resetSyncTriggerForTest(): void {51 syncInFlight = false;52 lastState = { status: "idle" };53 runner = null;54}5556export function getSyncState(): SyncState {57 return lastState;58}5960616263646566export async function hydratePersistedSyncState(): Promise<void> {67 if (lastState.lastSyncAt !== undefined) return;68 const persisted = await getLastSyncAt();69 if (typeof persisted === "number") {70 lastState = { ...lastState, lastSyncAt: persisted };71 }72}7374export async function triggerSyncIfReachable(opts: {75 reason: SyncTriggerReason;76 force?: boolean;77}): Promise<void> {78 79 80 81 if (syncInFlight && !opts.force) return;82 syncInFlight = true;8384 try {85 const devices = await listPairedDevices();86 const hosts = devices.filter(87 (d) =>88 (d.deviceType === "android" || d.deviceType === "ios") &&89 d.trustSource === "direct",90 );91 if (hosts.length === 0) return;92 if (!runner) return;9394 setState({ status: "syncing", reason: opts.reason, error: undefined });95 await runSyncRounds(hosts);96 } finally {97 syncInFlight = false;98 }99}100101async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {102 if (!runner) return;103104 let entriesReceived = 0;105 let entriesSent = 0;106 let anySucceeded = false;107 let firstError: string | undefined;108109 for (const host of hosts) {110 try {111 const result = await runner(host);112 entriesReceived += result.entriesReceived;113 entriesSent += result.entriesSent;114 await markLastSyncedAt(host.deviceId, Date.now());115 anySucceeded = true;116 } catch (err) {117 if (firstError === undefined) {118 firstError = (err as Error)?.message ?? String(err);119 }120 }121 }122123 if (anySucceeded) {124 const now = Date.now();125 setState({126 status: "idle",127 lastSyncAt: now,128 entriesReceived,129 entriesSent,130 error: undefined,131 });132 133 134 135 void setLastSyncAt(now).catch(() => undefined);136 void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);137 } else {138 setState({139 status: "error",140 error: firstError ?? "no_host_reachable",141 });142 }143}144145function setState(patch: Partial<SyncState>): void {146 lastState = { ...lastState, ...patch };147 broadcast(lastState);148}149150function broadcast(state: SyncState): void {151 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;152 try {153 const maybeThenable = chrome.runtime.sendMessage({154 type: "SYNC_STATE_CHANGED",155 payload: state,156 });157 if (158 maybeThenable &&159 typeof (maybeThenable as Promise<unknown>).catch === "function"160 ) {161 (maybeThenable as Promise<unknown>).catch(() => {162 163 });164 }165 } catch {166 167 }168}