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";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" || d.deviceType === "ios") &&115 (d.trustSource === "direct" || d.trustSource === "vouched"),116 );117 if (hosts.length === 0) return;118 if (!state.runner) return;119120 setState({ status: "syncing", reason: opts.reason, error: undefined });121 await runSyncRounds(hosts);122 } finally {123 state.syncInFlight = false;124 }125}126127async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {128 const state = getSyncTriggerState();129 if (!state.runner) return;130131 let entriesReceived = 0;132 let entriesSent = 0;133 let anySucceeded = false;134 let firstError: string | undefined;135136 for (const host of hosts) {137 try {138 const result = await state.runner(host);139 if (!result.ok) {140 if (firstError === undefined) firstError = result.error;141 continue;142 }143 entriesReceived += result.entriesReceived;144 entriesSent += result.entriesSent;145 await markLastSyncedAt(host.deviceId, Date.now());146 anySucceeded = true;147 } catch (err) {148 if (firstError === undefined) {149 firstError = (err as Error)?.message ?? String(err);150 }151 }152 }153154 if (anySucceeded) {155 const now = Date.now();156 setState({157 status: "idle",158 lastSyncAt: now,159 entriesReceived,160 entriesSent,161 error: undefined,162 });163 164 165 166 void setLastSyncAt(now).catch(() => undefined);167 void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);168 } else {169 setState({170 status: "error",171 error: firstError ?? "no_host_reachable",172 });173 }174}175176function setState(patch: Partial<SyncState>): void {177 const state = getSyncTriggerState();178 state.lastState = { ...state.lastState, ...patch };179 broadcast(state.lastState);180}181182function broadcast(state: SyncState): void {183 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;184 try {185 const maybeThenable = chrome.runtime.sendMessage({186 type: "SYNC_STATE_CHANGED",187 payload: state,188 });189 if (190 maybeThenable &&191 typeof (maybeThenable as Promise<unknown>).catch === "function"192 ) {193 (maybeThenable as Promise<unknown>).catch(() => {194 195 });196 }197 } catch {198 199 }200}