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 type SyncRunResult =36 | { ok: true; entriesReceived: number; entriesSent: number }37 | { ok: false; error: string };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) =>87 (d.deviceType === "android" || d.deviceType === "ios") &&88 d.trustSource === "direct",89 );90 if (hosts.length === 0) return;91 if (!runner) return;9293 setState({ status: "syncing", reason: opts.reason, error: undefined });94 await runSyncRounds(hosts);95 } finally {96 syncInFlight = false;97 }98}99100async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {101 if (!runner) return;102103 let entriesReceived = 0;104 let entriesSent = 0;105 let anySucceeded = false;106 let firstError: string | undefined;107108 for (const host of hosts) {109 try {110 const result = await runner(host);111 if (!result.ok) {112 if (firstError === undefined) firstError = result.error;113 continue;114 }115 entriesReceived += result.entriesReceived;116 entriesSent += result.entriesSent;117 await markLastSyncedAt(host.deviceId, Date.now());118 anySucceeded = true;119 } catch (err) {120 if (firstError === undefined) {121 firstError = (err as Error)?.message ?? String(err);122 }123 }124 }125126 if (anySucceeded) {127 const now = Date.now();128 setState({129 status: "idle",130 lastSyncAt: now,131 entriesReceived,132 entriesSent,133 error: undefined,134 });135 136 137 138 void setLastSyncAt(now).catch(() => undefined);139 void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);140 } else {141 setState({142 status: "error",143 error: firstError ?? "no_host_reachable",144 });145 }146}147148function setState(patch: Partial<SyncState>): void {149 lastState = { ...lastState, ...patch };150 broadcast(lastState);151}152153function broadcast(state: SyncState): void {154 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;155 try {156 const maybeThenable = chrome.runtime.sendMessage({157 type: "SYNC_STATE_CHANGED",158 payload: state,159 });160 if (161 maybeThenable &&162 typeof (maybeThenable as Promise<unknown>).catch === "function"163 ) {164 (maybeThenable as Promise<unknown>).catch(() => {165 166 });167 }168 } catch {169 170 }171}