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";2324252627282930313233export type SyncTriggerReason =34 | "alarm"35 | "startup"36 | "device_list_changed"37 | "vault_change"38 | "foreground"39 | "manual";4041export type SyncRunStatus = "idle" | "syncing" | "error";4243export interface SyncState {44 status: SyncRunStatus;45 reason?: SyncTriggerReason;46 47 lastSyncAt?: number;48 entriesReceived?: number;49 entriesSent?: number;50 error?: string;51 outcome?: SyncOutcome;52 53 detail?: string;54 finishedAt?: number;55}5657export type SyncRunResult =58 | { ok: true; entriesReceived: number; entriesSent: number }59 | { ok: false; error: string };6061export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;6263interface PeachSyncTriggerState {64 syncInFlight: boolean;65 lastState: SyncState;66 runner: RunSyncWithDevice | null;67}6869function getSyncTriggerState(): PeachSyncTriggerState {70 const g = globalThis as typeof globalThis & {71 __peachSyncTriggerState?: PeachSyncTriggerState;72 };73 if (!g.__peachSyncTriggerState) {74 g.__peachSyncTriggerState = {75 syncInFlight: false,76 lastState: { status: "idle" },77 runner: null,78 };79 }80 return g.__peachSyncTriggerState;81}8283export function setSyncRunner(fn: RunSyncWithDevice | null): void {84 getSyncTriggerState().runner = fn;85}8687export function _resetSyncTriggerForTest(): void {88 const state = getSyncTriggerState();89 state.syncInFlight = false;90 state.lastState = { status: "idle" };91 state.runner = null;92}9394export function getSyncState(): SyncState {95 return getSyncTriggerState().lastState;96}979899100101102103104export async function hydratePersistedSyncState(): Promise<void> {105 const state = getSyncTriggerState();106 if (state.lastState.lastSyncAt === undefined) {107 const persisted = await getLastSyncAt();108 if (typeof persisted === "number") {109 state.lastState = { ...state.lastState, lastSyncAt: persisted };110 }111 }112 113 114 115 if (state.lastState.outcome === undefined) {116 const last = await getLastSyncResult();117 if (last) {118 state.lastState = {119 ...state.lastState,120 outcome: last.outcome,121 detail: last.detail,122 finishedAt: last.at,123 entriesReceived: last.entriesReceived,124 entriesSent: last.entriesSent,125 };126 }127 }128}129130export async function triggerSyncIfReachable(opts: {131 reason: SyncTriggerReason;132 force?: boolean;133}): Promise<void> {134 const state = getSyncTriggerState();135 136 137 138 if (state.syncInFlight && !opts.force) {139 140 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);141 return;142 }143 state.syncInFlight = true;144145 try {146 const devices = await listPairedDevices();147 148 149 150 151 152 153 154 155 const hosts = devices.filter(156 (d) =>157 (d.deviceType === "android" || d.deviceType === "ios") &&158 (d.trustSource === "direct" || d.trustSource === "vouched"),159 );160 if (devices.length === 0) {161 finish({ outcome: "no_paired_devices" });162 return;163 }164 if (hosts.length === 0) {165 166 167 finish({ outcome: "no_eligible_host", detail: inventory(devices) });168 return;169 }170 if (!state.runner) {171 finish({ outcome: "runner_unavailable" });172 return;173 }174175 setState({176 status: "syncing",177 reason: opts.reason,178 error: undefined,179 outcome: undefined,180 detail: undefined,181 });182 log(183 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,184 );185 await runSyncRounds(hosts);186 } finally {187 state.syncInFlight = false;188 }189}190191async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {192 const state = getSyncTriggerState();193 if (!state.runner) {194 finish({ outcome: "runner_unavailable" });195 return;196 }197198 let entriesReceived = 0;199 let entriesSent = 0;200 let anySucceeded = false;201 let firstError: string | undefined;202203 for (const host of hosts) {204 try {205 const result = await state.runner(host);206 if (!result.ok) {207 log(`host ${describeHost(host)} refused: ${result.error}`);208 if (firstError === undefined) firstError = result.error;209 continue;210 }211 entriesReceived += result.entriesReceived;212 entriesSent += result.entriesSent;213 await markLastSyncedAt(host.deviceId, Date.now());214 anySucceeded = true;215 } catch (err) {216 const message = (err as Error)?.message ?? String(err);217 log(`host ${describeHost(host)} threw: ${message}`);218 if (firstError === undefined) firstError = message;219 }220 }221222 if (anySucceeded) {223 const now = Date.now();224 finish({225 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",226 227 228 detail: firstError,229 entriesReceived,230 entriesSent,231 });232 233 234 void setLastSyncAt(now).catch(() => undefined);235 } else {236 const error = firstError ?? "no_host_reachable";237 finish({ outcome: classifySyncError(error), detail: error });238 }239}240241242243244245246function classifySyncError(error: string): SyncOutcome {247 if (error.includes("VAULT_LOCKED")) return "vault_locked";248 if (error.includes("sync_disabled")) return "sync_disabled";249 if (error.includes("private_relay_unset")) return "private_relay_unset";250 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";251 if (error.includes("sync_timeout")) return "sync_timeout";252 if (error.startsWith("relay_sync_failed")) {253 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)254 ? "handshake_failed"255 : "relay_unreachable";256 }257 return "run_failed";258}259260interface SyncFinish {261 outcome: SyncOutcome;262 detail?: string;263 entriesReceived?: number;264 entriesSent?: number;265}266267268269270271272function finish(result: SyncFinish): void {273 const now = Date.now();274 const failed = isSyncFailure(result.outcome);275 const entriesReceived = result.entriesReceived ?? 0;276 const entriesSent = result.entriesSent ?? 0;277 setState({278 status: failed ? "error" : "idle",279 outcome: result.outcome,280 detail: result.detail,281 error: failed ? result.detail ?? result.outcome : undefined,282 finishedAt: now,283 entriesReceived,284 entriesSent,285 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"286 ? { lastSyncAt: now }287 : {}),288 });289 log(290 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +291 (entriesReceived + entriesSent > 0292 ? ` (sent ${entriesSent}, received ${entriesReceived})`293 : ""),294 );295 void setLastSyncResult({296 outcome: result.outcome,297 detail: result.detail,298 at: now,299 entriesReceived,300 entriesSent,301 }).catch(() => undefined);302}303304305function inventory(devices: PairedDevice[]): string {306 return devices.map(describeHost).join("; ");307}308309function describeHost(d: PairedDevice): string {310 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;311}312313314315316317function log(message: string): void {318 console.info(`[Peach sync] ${message}`);319}320321function setState(patch: Partial<SyncState>): void {322 const state = getSyncTriggerState();323 state.lastState = { ...state.lastState, ...patch };324 broadcast(state.lastState);325}326327function broadcast(state: SyncState): void {328 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;329 try {330 const maybeThenable = chrome.runtime.sendMessage({331 type: "SYNC_STATE_CHANGED",332 payload: state,333 });334 if (335 maybeThenable &&336 typeof (maybeThenable as Promise<unknown>).catch === "function"337 ) {338 (maybeThenable as Promise<unknown>).catch(() => {339 340 });341 }342 } catch {343 344 }345}