12345678910111213import {14 getLastSyncAt,15 listPairedDevices,16 markLastSyncedAt,17 setLastSyncAt,18} from "./paired-devices";19import type { PairedDevice } from "../shared/sync-types";2021export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";2223export type SyncRunStatus = "idle" | "syncing" | "error";2425export interface SyncState {26 status: SyncRunStatus;27 reason?: SyncTriggerReason;28 lastSyncAt?: number;29 entriesReceived?: number;30 entriesSent?: number;31 error?: string;32}3334export interface SyncRunResult {35 entriesReceived: number;36 entriesSent: number;37}3839export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4041let syncInFlight = false;42let lastState: SyncState = { status: "idle" };43let runner: RunSyncWithDevice | null = null;4445export function setSyncRunner(fn: RunSyncWithDevice | null): void {46 runner = fn;47}4849export function _resetSyncTriggerForTest(): void {50 syncInFlight = false;51 lastState = { status: "idle" };52 runner = null;53}5455export function getSyncState(): SyncState {56 return lastState;57}5859606162636465export async function hydratePersistedSyncState(): Promise<void> {66 if (lastState.lastSyncAt !== undefined) return;67 const persisted = await getLastSyncAt();68 if (typeof persisted === "number") {69 lastState = { ...lastState, lastSyncAt: persisted };70 }71}7273export async function triggerSyncIfReachable(opts: {74 reason: SyncTriggerReason;75 force?: boolean;76}): Promise<void> {77 78 79 80 if (syncInFlight && !opts.force) return;81 syncInFlight = true;8283 try {84 const devices = await listPairedDevices();85 const hosts = devices.filter(86 (d) => d.deviceType === "android" || d.deviceType === "ios",87 );88 if (hosts.length === 0) return;89 if (!runner) return;9091 setState({ status: "syncing", reason: opts.reason, error: undefined });92 await runSyncRounds(hosts);93 } finally {94 syncInFlight = false;95 }96}9798async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {99 if (!runner) return;100101 let entriesReceived = 0;102 let entriesSent = 0;103 let anySucceeded = false;104 let firstError: string | undefined;105106 for (const host of hosts) {107 try {108 const result = await runner(host);109 entriesReceived += result.entriesReceived;110 entriesSent += result.entriesSent;111 await markLastSyncedAt(host.deviceId, Date.now());112 anySucceeded = true;113 } catch (err) {114 if (firstError === undefined) {115 firstError = (err as Error)?.message ?? String(err);116 }117 }118 }119120 if (anySucceeded) {121 const now = Date.now();122 setState({123 status: "idle",124 lastSyncAt: now,125 entriesReceived,126 entriesSent,127 error: undefined,128 });129 130 131 void setLastSyncAt(now).catch(() => undefined);132 } else {133 setState({134 status: "error",135 error: firstError ?? "no_host_reachable",136 });137 }138}139140function setState(patch: Partial<SyncState>): void {141 lastState = { ...lastState, ...patch };142 broadcast(lastState);143}144145function broadcast(state: SyncState): void {146 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;147 try {148 const maybeThenable = chrome.runtime.sendMessage({149 type: "SYNC_STATE_CHANGED",150 payload: state,151 });152 if (153 maybeThenable &&154 typeof (maybeThenable as Promise<unknown>).catch === "function"155 ) {156 (maybeThenable as Promise<unknown>).catch(() => {157 158 });159 }160 } catch {161 162 }163}