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" ||107 d.deviceType === "ios" ||108 d.deviceType === "desktop") &&109 d.trustSource === "direct",110 );111 if (hosts.length === 0) return;112 if (!state.runner) return;113114 setState({ status: "syncing", reason: opts.reason, error: undefined });115 await runSyncRounds(hosts);116 } finally {117 state.syncInFlight = false;118 }119}120121async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {122 const state = getSyncTriggerState();123 if (!state.runner) return;124125 let entriesReceived = 0;126 let entriesSent = 0;127 let anySucceeded = false;128 let firstError: string | undefined;129130 for (const host of hosts) {131 try {132 const result = await state.runner(host);133 if (!result.ok) {134 if (firstError === undefined) firstError = result.error;135 continue;136 }137 entriesReceived += result.entriesReceived;138 entriesSent += result.entriesSent;139 await markLastSyncedAt(host.deviceId, Date.now());140 anySucceeded = true;141 } catch (err) {142 if (firstError === undefined) {143 firstError = (err as Error)?.message ?? String(err);144 }145 }146 }147148 if (anySucceeded) {149 const now = Date.now();150 setState({151 status: "idle",152 lastSyncAt: now,153 entriesReceived,154 entriesSent,155 error: undefined,156 });157 158 159 160 void setLastSyncAt(now).catch(() => undefined);161 void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);162 } else {163 setState({164 status: "error",165 error: firstError ?? "no_host_reachable",166 });167 }168}169170function setState(patch: Partial<SyncState>): void {171 const state = getSyncTriggerState();172 state.lastState = { ...state.lastState, ...patch };173 broadcast(state.lastState);174}175176function broadcast(state: SyncState): void {177 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;178 try {179 const maybeThenable = chrome.runtime.sendMessage({180 type: "SYNC_STATE_CHANGED",181 payload: state,182 });183 if (184 maybeThenable &&185 typeof (maybeThenable as Promise<unknown>).catch === "function"186 ) {187 (maybeThenable as Promise<unknown>).catch(() => {188 189 });190 }191 } catch {192 193 }194}