12345678910111213import {14 getLastSyncAt,15 getLastSyncResult,16 listPairedDevices,17 markLastSyncedAt,18 setLastSyncAt,19 setLastSyncResult,20} from "./paired-devices";21import type { PairedDevice } from "../shared/sync-types";22import { isSyncFailure, type SyncOutcome } from "../shared/sync-outcome";2324export type SyncTriggerReason = "vault_change" | "foreground" | "manual";2526export type SyncRunStatus = "idle" | "syncing" | "error";2728export interface SyncState {29 status: SyncRunStatus;30 reason?: SyncTriggerReason;31 32 lastSyncAt?: number;33 entriesReceived?: number;34 entriesSent?: number;35 error?: string;36 outcome?: SyncOutcome;37 38 detail?: string;39 finishedAt?: number;40}4142export type SyncRunResult =43 | { ok: true; entriesReceived: number; entriesSent: number }44 | { ok: false; error: string };4546export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4748interface PeachSyncTriggerState {49 syncInFlight: boolean;50 lastState: SyncState;51 runner: RunSyncWithDevice | null;52}5354function getSyncTriggerState(): PeachSyncTriggerState {55 const g = globalThis as typeof globalThis & {56 __peachSyncTriggerState?: PeachSyncTriggerState;57 };58 if (!g.__peachSyncTriggerState) {59 g.__peachSyncTriggerState = {60 syncInFlight: false,61 lastState: { status: "idle" },62 runner: null,63 };64 }65 return g.__peachSyncTriggerState;66}6768export function setSyncRunner(fn: RunSyncWithDevice | null): void {69 getSyncTriggerState().runner = fn;70}7172export function _resetSyncTriggerForTest(): void {73 const state = getSyncTriggerState();74 state.syncInFlight = false;75 state.lastState = { status: "idle" };76 state.runner = null;77}7879export function getSyncState(): SyncState {80 return getSyncTriggerState().lastState;81}8283848586878889export async function hydratePersistedSyncState(): Promise<void> {90 const state = getSyncTriggerState();91 if (state.lastState.lastSyncAt === undefined) {92 const persisted = await getLastSyncAt();93 if (typeof persisted === "number") {94 state.lastState = { ...state.lastState, lastSyncAt: persisted };95 }96 }97 98 99 100 if (state.lastState.outcome === undefined) {101 const last = await getLastSyncResult();102 if (last) {103 state.lastState = {104 ...state.lastState,105 outcome: last.outcome,106 detail: last.detail,107 finishedAt: last.at,108 entriesReceived: last.entriesReceived,109 entriesSent: last.entriesSent,110 };111 }112 }113}114115export async function triggerSyncIfReachable(opts: {116 reason: SyncTriggerReason;117 force?: boolean;118}): Promise<void> {119 const state = getSyncTriggerState();120 121 122 123 if (state.syncInFlight && !opts.force) {124 125 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);126 return;127 }128 state.syncInFlight = true;129130 try {131 const devices = await listPairedDevices();132 133 134 135 136 137 138 139 140 const hosts = devices.filter(141 (d) =>142 (d.deviceType === "android" || d.deviceType === "ios") &&143 (d.trustSource === "direct" || d.trustSource === "vouched"),144 );145 if (devices.length === 0) {146 finish({ outcome: "no_paired_devices" });147 return;148 }149 if (hosts.length === 0) {150 151 152 finish({ outcome: "no_eligible_host", detail: inventory(devices) });153 return;154 }155 if (!state.runner) {156 finish({ outcome: "runner_unavailable" });157 return;158 }159160 setState({161 status: "syncing",162 reason: opts.reason,163 error: undefined,164 outcome: undefined,165 detail: undefined,166 });167 log(168 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,169 );170 await runSyncRounds(hosts);171 } finally {172 state.syncInFlight = false;173 }174}175176async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {177 const state = getSyncTriggerState();178 if (!state.runner) {179 finish({ outcome: "runner_unavailable" });180 return;181 }182183 let entriesReceived = 0;184 let entriesSent = 0;185 let anySucceeded = false;186 let firstError: string | undefined;187188 for (const host of hosts) {189 try {190 const result = await state.runner(host);191 if (!result.ok) {192 log(`host ${describeHost(host)} refused: ${result.error}`);193 if (firstError === undefined) firstError = result.error;194 continue;195 }196 entriesReceived += result.entriesReceived;197 entriesSent += result.entriesSent;198 await markLastSyncedAt(host.deviceId, Date.now());199 anySucceeded = true;200 } catch (err) {201 const message = (err as Error)?.message ?? String(err);202 log(`host ${describeHost(host)} threw: ${message}`);203 if (firstError === undefined) firstError = message;204 }205 }206207 if (anySucceeded) {208 const now = Date.now();209 finish({210 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",211 212 213 detail: firstError,214 entriesReceived,215 entriesSent,216 });217 218 219 void setLastSyncAt(now).catch(() => undefined);220 } else {221 const error = firstError ?? "no_host_reachable";222 finish({ outcome: classifySyncError(error), detail: error });223 }224}225226227228229230231function classifySyncError(error: string): SyncOutcome {232 if (error.includes("VAULT_LOCKED")) return "vault_locked";233 if (error.includes("sync_disabled")) return "sync_disabled";234 if (error.includes("private_relay_unset")) return "private_relay_unset";235 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";236 if (error.includes("sync_timeout")) return "sync_timeout";237 if (error.startsWith("relay_sync_failed")) {238 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)239 ? "handshake_failed"240 : "relay_unreachable";241 }242 return "run_failed";243}244245interface SyncFinish {246 outcome: SyncOutcome;247 detail?: string;248 entriesReceived?: number;249 entriesSent?: number;250}251252253254255256257function finish(result: SyncFinish): void {258 const now = Date.now();259 const failed = isSyncFailure(result.outcome);260 const entriesReceived = result.entriesReceived ?? 0;261 const entriesSent = result.entriesSent ?? 0;262 setState({263 status: failed ? "error" : "idle",264 outcome: result.outcome,265 detail: result.detail,266 error: failed ? result.detail ?? result.outcome : undefined,267 finishedAt: now,268 entriesReceived,269 entriesSent,270 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"271 ? { lastSyncAt: now }272 : {}),273 });274 log(275 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +276 (entriesReceived + entriesSent > 0277 ? ` (sent ${entriesSent}, received ${entriesReceived})`278 : ""),279 );280 void setLastSyncResult({281 outcome: result.outcome,282 detail: result.detail,283 at: now,284 entriesReceived,285 entriesSent,286 }).catch(() => undefined);287}288289290function inventory(devices: PairedDevice[]): string {291 return devices.map(describeHost).join("; ");292}293294function describeHost(d: PairedDevice): string {295 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;296}297298299300301302function log(message: string): void {303 console.info(`[Peach sync] ${message}`);304}305306function setState(patch: Partial<SyncState>): void {307 const state = getSyncTriggerState();308 state.lastState = { ...state.lastState, ...patch };309 broadcast(state.lastState);310}311312function broadcast(state: SyncState): void {313 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;314 try {315 const maybeThenable = chrome.runtime.sendMessage({316 type: "SYNC_STATE_CHANGED",317 payload: state,318 });319 if (320 maybeThenable &&321 typeof (maybeThenable as Promise<unknown>).catch === "function"322 ) {323 (maybeThenable as Promise<unknown>).catch(() => {324 325 });326 }327 } catch {328 329 }330}