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>;66export type SelectSyncDialTargets = (67 devices: PairedDevice[],68) => Promise<PairedDevice[]>;6970interface PeachSyncTriggerState {71 syncInFlight: boolean;72 lastState: SyncState;73 runner: RunSyncWithDevice | null;74 selectDialTargets: SelectSyncDialTargets | null;75}7677function getSyncTriggerState(): PeachSyncTriggerState {78 const g = globalThis as typeof globalThis & {79 __peachSyncTriggerState?: PeachSyncTriggerState;80 };81 if (!g.__peachSyncTriggerState) {82 g.__peachSyncTriggerState = {83 syncInFlight: false,84 lastState: { status: "idle" },85 runner: null,86 selectDialTargets: null,87 };88 }89 return g.__peachSyncTriggerState;90}9192export function setSyncRunner(fn: RunSyncWithDevice | null): void {93 getSyncTriggerState().runner = fn;94}9596export function setSyncDialTargetSelector(97 fn: SelectSyncDialTargets | null,98): void {99 getSyncTriggerState().selectDialTargets = fn;100}101102export function _resetSyncTriggerForTest(): void {103 const state = getSyncTriggerState();104 state.syncInFlight = false;105 state.lastState = { status: "idle" };106 state.runner = null;107 state.selectDialTargets = null;108}109110export function getSyncState(): SyncState {111 return getSyncTriggerState().lastState;112}113114115116117118119120export async function hydratePersistedSyncState(): Promise<void> {121 const state = getSyncTriggerState();122 if (state.lastState.lastSyncAt === undefined) {123 const persisted = await getLastSyncAt();124 if (typeof persisted === "number") {125 state.lastState = { ...state.lastState, lastSyncAt: persisted };126 }127 }128 129 130 131 if (state.lastState.outcome === undefined) {132 const last = await getLastSyncResult();133 if (last) {134 state.lastState = {135 ...state.lastState,136 outcome: last.outcome,137 detail: last.detail,138 finishedAt: last.at,139 entriesReceived: last.entriesReceived,140 entriesSent: last.entriesSent,141 };142 }143 }144}145146export async function triggerSyncIfReachable(opts: {147 reason: SyncTriggerReason;148 force?: boolean;149}): Promise<void> {150 const state = getSyncTriggerState();151 152 153 154 if (state.syncInFlight && !opts.force) {155 156 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);157 return;158 }159 state.syncInFlight = true;160161 try {162 const devices = await listPairedDevices();163 const routeCapable = devices.filter(164 (d) =>165 (166 d.deviceType === "extension" ||167 d.deviceType === "android" ||168 d.deviceType === "ios"169 ) &&170 (d.trustSource === "direct" || d.trustSource === "vouched"),171 );172 173 174 175 const hosts = state.selectDialTargets176 ? await state.selectDialTargets(routeCapable)177 : routeCapable.filter(178 (d) => d.deviceType === "android" || d.deviceType === "ios",179 );180 if (devices.length === 0) {181 finish({ outcome: "no_paired_devices" });182 return;183 }184 if (hosts.length === 0) {185 if (routeCapable.length > 0 && state.selectDialTargets) {186 finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });187 return;188 }189 190 191 finish({ outcome: "no_eligible_host", detail: inventory(devices) });192 return;193 }194 if (!state.runner) {195 finish({ outcome: "runner_unavailable" });196 return;197 }198199 setState({200 status: "syncing",201 reason: opts.reason,202 error: undefined,203 outcome: undefined,204 detail: undefined,205 });206 log(207 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,208 );209 await runSyncRounds(hosts);210 } finally {211 state.syncInFlight = false;212 }213}214215async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {216 const state = getSyncTriggerState();217 if (!state.runner) {218 finish({ outcome: "runner_unavailable" });219 return;220 }221222 let entriesReceived = 0;223 let entriesSent = 0;224 let anySucceeded = false;225 let firstError: string | undefined;226227 for (const host of hosts) {228 try {229 const result = await state.runner(host);230 if (!result.ok) {231 log(`host ${describeHost(host)} refused: ${result.error}`);232 if (firstError === undefined) firstError = result.error;233 continue;234 }235 entriesReceived += result.entriesReceived;236 entriesSent += result.entriesSent;237 await markLastSyncedAt(host.deviceId, Date.now());238 anySucceeded = true;239 } catch (err) {240 const message = (err as Error)?.message ?? String(err);241 log(`host ${describeHost(host)} threw: ${message}`);242 if (firstError === undefined) firstError = message;243 }244 }245246 if (anySucceeded) {247 const now = Date.now();248 finish({249 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",250 251 252 detail: firstError,253 entriesReceived,254 entriesSent,255 });256 257 258 void setLastSyncAt(now).catch(() => undefined);259 } else {260 const error = firstError ?? "no_host_reachable";261 finish({ outcome: classifySyncError(error), detail: error });262 }263}264265266267268269270function classifySyncError(error: string): SyncOutcome {271 if (error.includes("VAULT_LOCKED")) return "vault_locked";272 if (error.includes("sync_disabled")) return "sync_disabled";273 if (error.includes("private_relay_unset")) return "private_relay_unset";274 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";275 if (error.includes("sync_timeout")) return "sync_timeout";276 277 278 279 280 281 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {282 return "connection_lost";283 }284 if (error.startsWith("relay_sync_failed")) {285 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)286 ? "handshake_failed"287 : "relay_unreachable";288 }289 return "run_failed";290}291292interface SyncFinish {293 outcome: SyncOutcome;294 detail?: string;295 entriesReceived?: number;296 entriesSent?: number;297}298299300301302303304function finish(result: SyncFinish): void {305 const now = Date.now();306 const failed = isSyncFailure(result.outcome);307 const entriesReceived = result.entriesReceived ?? 0;308 const entriesSent = result.entriesSent ?? 0;309 setState({310 status: failed ? "error" : "idle",311 outcome: result.outcome,312 detail: result.detail,313 error: failed ? result.detail ?? result.outcome : undefined,314 finishedAt: now,315 entriesReceived,316 entriesSent,317 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"318 ? { lastSyncAt: now }319 : {}),320 });321 log(322 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +323 (entriesReceived + entriesSent > 0324 ? ` (sent ${entriesSent}, received ${entriesReceived})`325 : ""),326 );327 void setLastSyncResult({328 outcome: result.outcome,329 detail: result.detail,330 at: now,331 entriesReceived,332 entriesSent,333 }).catch(() => undefined);334}335336337function inventory(devices: PairedDevice[]): string {338 return devices.map(describeHost).join("; ");339}340341function describeHost(d: PairedDevice): string {342 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;343}344345346347348349function log(message: string): void {350 console.info(`[Peach sync] ${message}`);351}352353function setState(patch: Partial<SyncState>): void {354 const state = getSyncTriggerState();355 state.lastState = { ...state.lastState, ...patch };356 broadcast(state.lastState);357}358359function broadcast(state: SyncState): void {360 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;361 try {362 const maybeThenable = chrome.runtime.sendMessage({363 type: "SYNC_STATE_CHANGED",364 payload: state,365 });366 if (367 maybeThenable &&368 typeof (maybeThenable as Promise<unknown>).catch === "function"369 ) {370 (maybeThenable as Promise<unknown>).catch(() => {371 372 });373 }374 } catch {375 376 }377}