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 105 106 107 108 109 110 111 112 const hosts = devices.filter(113 (d) =>114 (d.deviceType === "android" ||115 d.deviceType === "ios" ||116 d.deviceType === "desktop") &&117 (d.trustSource === "direct" || d.trustSource === "vouched"),118 );119 if (hosts.length === 0) return;120 if (!state.runner) return;121122 setState({ status: "syncing", reason: opts.reason, error: undefined });123 await runSyncRounds(hosts);124 } finally {125 state.syncInFlight = false;126 }127}128129async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {130 const state = getSyncTriggerState();131 if (!state.runner) return;132133 let entriesReceived = 0;134 let entriesSent = 0;135 let anySucceeded = false;136 let firstError: string | undefined;137138 for (const host of hosts) {139 try {140 const result = await state.runner(host);141 if (!result.ok) {142 if (firstError === undefined) firstError = result.error;143 continue;144 }145 entriesReceived += result.entriesReceived;146 entriesSent += result.entriesSent;147 await markLastSyncedAt(host.deviceId, Date.now());148 anySucceeded = true;149 } catch (err) {150 if (firstError === undefined) {151 firstError = (err as Error)?.message ?? String(err);152 }153 }154 }155156 if (anySucceeded) {157 const now = Date.now();158 setState({159 status: "idle",160 lastSyncAt: now,161 entriesReceived,162 entriesSent,163 error: undefined,164 });165 166 167 168 void setLastSyncAt(now).catch(() => undefined);169 void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);170 } else {171 setState({172 status: "error",173 error: firstError ?? "no_host_reachable",174 });175 }176}177178function setState(patch: Partial<SyncState>): void {179 const state = getSyncTriggerState();180 state.lastState = { ...state.lastState, ...patch };181 broadcast(state.lastState);182}183184function broadcast(state: SyncState): void {185 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;186 try {187 const maybeThenable = chrome.runtime.sendMessage({188 type: "SYNC_STATE_CHANGED",189 payload: state,190 });191 if (192 maybeThenable &&193 typeof (maybeThenable as Promise<unknown>).catch === "function"194 ) {195 (maybeThenable as Promise<unknown>).catch(() => {196 197 });198 }199 } catch {200 201 }202}