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 {23 isConnectionClosedError,24 isSyncFailure,25 type SyncOutcome,26} from "../shared/sync-outcome";2728293031323334353637export type SyncTriggerReason =38 | "alarm"39 | "startup"40 | "device_list_changed"41 | "vault_change"42 | "foreground"43 | "manual";4445export type SyncRunStatus = "idle" | "syncing" | "error";4647export interface SyncState {48 status: SyncRunStatus;49 reason?: SyncTriggerReason;50 51 lastSyncAt?: number;52 entriesReceived?: number;53 entriesSent?: number;54 error?: string;55 outcome?: SyncOutcome;56 57 detail?: string;58 finishedAt?: number;59}6061export type SyncRunResult =62 | { ok: true; entriesReceived: number; entriesSent: number }63 | { ok: false; error: string };6465export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;6667interface PeachSyncTriggerState {68 syncInFlight: boolean;69 lastState: SyncState;70 runner: RunSyncWithDevice | null;71}7273function getSyncTriggerState(): PeachSyncTriggerState {74 const g = globalThis as typeof globalThis & {75 __peachSyncTriggerState?: PeachSyncTriggerState;76 };77 if (!g.__peachSyncTriggerState) {78 g.__peachSyncTriggerState = {79 syncInFlight: false,80 lastState: { status: "idle" },81 runner: null,82 };83 }84 return g.__peachSyncTriggerState;85}8687export function setSyncRunner(fn: RunSyncWithDevice | null): void {88 getSyncTriggerState().runner = fn;89}9091export function _resetSyncTriggerForTest(): void {92 const state = getSyncTriggerState();93 state.syncInFlight = false;94 state.lastState = { status: "idle" };95 state.runner = null;96}9798export function getSyncState(): SyncState {99 return getSyncTriggerState().lastState;100}101102103104105106107108export async function hydratePersistedSyncState(): Promise<void> {109 const state = getSyncTriggerState();110 if (state.lastState.lastSyncAt === undefined) {111 const persisted = await getLastSyncAt();112 if (typeof persisted === "number") {113 state.lastState = { ...state.lastState, lastSyncAt: persisted };114 }115 }116 117 118 119 if (state.lastState.outcome === undefined) {120 const last = await getLastSyncResult();121 if (last) {122 state.lastState = {123 ...state.lastState,124 outcome: last.outcome,125 detail: last.detail,126 finishedAt: last.at,127 entriesReceived: last.entriesReceived,128 entriesSent: last.entriesSent,129 };130 }131 }132}133134export async function triggerSyncIfReachable(opts: {135 reason: SyncTriggerReason;136 force?: boolean;137}): Promise<void> {138 const state = getSyncTriggerState();139 140 141 142 if (state.syncInFlight && !opts.force) {143 144 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);145 return;146 }147 state.syncInFlight = true;148149 try {150 const devices = await listPairedDevices();151 152 153 154 155 156 157 158 159 const hosts = devices.filter(160 (d) =>161 (d.deviceType === "android" || d.deviceType === "ios") &&162 (d.trustSource === "direct" || d.trustSource === "vouched"),163 );164 if (devices.length === 0) {165 finish({ outcome: "no_paired_devices" });166 return;167 }168 if (hosts.length === 0) {169 170 171 finish({ outcome: "no_eligible_host", detail: inventory(devices) });172 return;173 }174 if (!state.runner) {175 finish({ outcome: "runner_unavailable" });176 return;177 }178179 setState({180 status: "syncing",181 reason: opts.reason,182 error: undefined,183 outcome: undefined,184 detail: undefined,185 });186 log(187 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,188 );189 await runSyncRounds(hosts);190 } finally {191 state.syncInFlight = false;192 }193}194195async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {196 const state = getSyncTriggerState();197 if (!state.runner) {198 finish({ outcome: "runner_unavailable" });199 return;200 }201202 let entriesReceived = 0;203 let entriesSent = 0;204 let anySucceeded = false;205 let firstError: string | undefined;206207 for (const host of hosts) {208 try {209 const result = await state.runner(host);210 if (!result.ok) {211 log(`host ${describeHost(host)} refused: ${result.error}`);212 if (firstError === undefined) firstError = result.error;213 continue;214 }215 entriesReceived += result.entriesReceived;216 entriesSent += result.entriesSent;217 await markLastSyncedAt(host.deviceId, Date.now());218 anySucceeded = true;219 } catch (err) {220 const message = (err as Error)?.message ?? String(err);221 log(`host ${describeHost(host)} threw: ${message}`);222 if (firstError === undefined) firstError = message;223 }224 }225226 if (anySucceeded) {227 const now = Date.now();228 finish({229 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",230 231 232 detail: firstError,233 entriesReceived,234 entriesSent,235 });236 237 238 void setLastSyncAt(now).catch(() => undefined);239 } else {240 const error = firstError ?? "no_host_reachable";241 finish({ outcome: classifySyncError(error), detail: error });242 }243}244245246247248249250function classifySyncError(error: string): SyncOutcome {251 if (error.includes("VAULT_LOCKED")) return "vault_locked";252 if (error.includes("sync_disabled")) return "sync_disabled";253 if (error.includes("private_relay_unset")) return "private_relay_unset";254 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";255 if (error.includes("sync_timeout")) return "sync_timeout";256 257 258 259 260 261 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {262 return "connection_lost";263 }264 if (error.startsWith("relay_sync_failed")) {265 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)266 ? "handshake_failed"267 : "relay_unreachable";268 }269 return "run_failed";270}271272interface SyncFinish {273 outcome: SyncOutcome;274 detail?: string;275 entriesReceived?: number;276 entriesSent?: number;277}278279280281282283284function finish(result: SyncFinish): void {285 const now = Date.now();286 const failed = isSyncFailure(result.outcome);287 const entriesReceived = result.entriesReceived ?? 0;288 const entriesSent = result.entriesSent ?? 0;289 setState({290 status: failed ? "error" : "idle",291 outcome: result.outcome,292 detail: result.detail,293 error: failed ? result.detail ?? result.outcome : undefined,294 finishedAt: now,295 entriesReceived,296 entriesSent,297 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"298 ? { lastSyncAt: now }299 : {}),300 });301 log(302 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +303 (entriesReceived + entriesSent > 0304 ? ` (sent ${entriesSent}, received ${entriesReceived})`305 : ""),306 );307 void setLastSyncResult({308 outcome: result.outcome,309 detail: result.detail,310 at: now,311 entriesReceived,312 entriesSent,313 }).catch(() => undefined);314}315316317function inventory(devices: PairedDevice[]): string {318 return devices.map(describeHost).join("; ");319}320321function describeHost(d: PairedDevice): string {322 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;323}324325326327328329function log(message: string): void {330 console.info(`[Peach sync] ${message}`);331}332333function setState(patch: Partial<SyncState>): void {334 const state = getSyncTriggerState();335 state.lastState = { ...state.lastState, ...patch };336 broadcast(state.lastState);337}338339function broadcast(state: SyncState): void {340 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;341 try {342 const maybeThenable = chrome.runtime.sendMessage({343 type: "SYNC_STATE_CHANGED",344 payload: state,345 });346 if (347 maybeThenable &&348 typeof (maybeThenable as Promise<unknown>).catch === "function"349 ) {350 (maybeThenable as Promise<unknown>).catch(() => {351 352 });353 }354 } catch {355 356 }357}