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>;4041interface PeachSyncTriggerState {42 syncInFlight: boolean;43 lastState: SyncState;44 runner: RunSyncWithDevice | null;45}4647function getSyncTriggerState(): PeachSyncTriggerState {48 const g = globalThis as typeof globalThis & {49 __peachSyncTriggerState?: PeachSyncTriggerState;50 };51 if (!g.__peachSyncTriggerState) {52 g.__peachSyncTriggerState = {53 syncInFlight: false,54 lastState: { status: "idle" },55 runner: null,56 };57 }58 return g.__peachSyncTriggerState;59}6061export function setSyncRunner(fn: RunSyncWithDevice | null): void {62 getSyncTriggerState().runner = fn;63}6465export function _resetSyncTriggerForTest(): void {66 const state = getSyncTriggerState();67 state.syncInFlight = false;68 state.lastState = { status: "idle" };69 state.runner = null;70}7172export function getSyncState(): SyncState {73 return getSyncTriggerState().lastState;74}7576777879808182export async function hydratePersistedSyncState(): Promise<void> {83 const state = getSyncTriggerState();84 if (state.lastState.lastSyncAt !== undefined) return;85 const persisted = await getLastSyncAt();86 if (typeof persisted === "number") {87 state.lastState = { ...state.lastState, lastSyncAt: persisted };88 }89}9091export async function triggerSyncIfReachable(opts: {92 reason: SyncTriggerReason;93 force?: boolean;94}): Promise<void> {95 const state = getSyncTriggerState();96 97 98 99 if (state.syncInFlight && !opts.force) return;100 state.syncInFlight = true;101102 try {103 const devices = await listPairedDevices();104 const hosts = devices.filter(105 (d) =>106 (d.deviceType === "android" || d.deviceType === "ios") &&107 d.trustSource === "direct",108 );109 if (hosts.length === 0) return;110 if (!state.runner) return;111112 setState({ status: "syncing", reason: opts.reason, error: undefined });113 await runSyncRounds(hosts);114 } finally {115 state.syncInFlight = false;116 }117}118119async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {120 const state = getSyncTriggerState();121 if (!state.runner) return;122123 let entriesReceived = 0;124 let entriesSent = 0;125 let anySucceeded = false;126 let firstError: string | undefined;127128 for (const host of hosts) {129 try {130 const result = await state.runner(host);131 if (!result.ok) {132 if (firstError === undefined) firstError = result.error;133 continue;134 }135 entriesReceived += result.entriesReceived;136 entriesSent += result.entriesSent;137 await markLastSyncedAt(host.deviceId, Date.now());138 anySucceeded = true;139 } catch (err) {140 if (firstError === undefined) {141 firstError = (err as Error)?.message ?? String(err);142 }143 }144 }145146 if (anySucceeded) {147 const now = Date.now();148 setState({149 status: "idle",150 lastSyncAt: now,151 entriesReceived,152 entriesSent,153 error: undefined,154 });155 156 157 158 void setLastSyncAt(now).catch(() => undefined);159 void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);160 } else {161 setState({162 status: "error",163 error: firstError ?? "no_host_reachable",164 });165 }166}167168function setState(patch: Partial<SyncState>): void {169 const state = getSyncTriggerState();170 state.lastState = { ...state.lastState, ...patch };171 broadcast(state.lastState);172}173174function broadcast(state: SyncState): void {175 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;176 try {177 const maybeThenable = chrome.runtime.sendMessage({178 type: "SYNC_STATE_CHANGED",179 payload: state,180 });181 if (182 maybeThenable &&183 typeof (maybeThenable as Promise<unknown>).catch === "function"184 ) {185 (maybeThenable as Promise<unknown>).catch(() => {186 187 });188 }189 } catch {190 191 }192}