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 186 187 finish({ outcome: "no_eligible_host", detail: inventory(devices) });188 return;189 }190 if (!state.runner) {191 finish({ outcome: "runner_unavailable" });192 return;193 }194195 setState({196 status: "syncing",197 reason: opts.reason,198 error: undefined,199 outcome: undefined,200 detail: undefined,201 });202 log(203 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,204 );205 await runSyncRounds(hosts);206 } finally {207 state.syncInFlight = false;208 }209}210211async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {212 const state = getSyncTriggerState();213 if (!state.runner) {214 finish({ outcome: "runner_unavailable" });215 return;216 }217218 let entriesReceived = 0;219 let entriesSent = 0;220 let anySucceeded = false;221 let firstError: string | undefined;222223 for (const host of hosts) {224 try {225 const result = await state.runner(host);226 if (!result.ok) {227 log(`host ${describeHost(host)} refused: ${result.error}`);228 if (firstError === undefined) firstError = result.error;229 continue;230 }231 entriesReceived += result.entriesReceived;232 entriesSent += result.entriesSent;233 await markLastSyncedAt(host.deviceId, Date.now());234 anySucceeded = true;235 } catch (err) {236 const message = (err as Error)?.message ?? String(err);237 log(`host ${describeHost(host)} threw: ${message}`);238 if (firstError === undefined) firstError = message;239 }240 }241242 if (anySucceeded) {243 const now = Date.now();244 finish({245 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",246 247 248 detail: firstError,249 entriesReceived,250 entriesSent,251 });252 253 254 void setLastSyncAt(now).catch(() => undefined);255 } else {256 const error = firstError ?? "no_host_reachable";257 finish({ outcome: classifySyncError(error), detail: error });258 }259}260261262263264265266function classifySyncError(error: string): SyncOutcome {267 if (error.includes("VAULT_LOCKED")) return "vault_locked";268 if (error.includes("sync_disabled")) return "sync_disabled";269 if (error.includes("private_relay_unset")) return "private_relay_unset";270 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";271 if (error.includes("sync_timeout")) return "sync_timeout";272 273 274 275 276 277 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {278 return "connection_lost";279 }280 if (error.startsWith("relay_sync_failed")) {281 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)282 ? "handshake_failed"283 : "relay_unreachable";284 }285 return "run_failed";286}287288interface SyncFinish {289 outcome: SyncOutcome;290 detail?: string;291 entriesReceived?: number;292 entriesSent?: number;293}294295296297298299300function finish(result: SyncFinish): void {301 const now = Date.now();302 const failed = isSyncFailure(result.outcome);303 const entriesReceived = result.entriesReceived ?? 0;304 const entriesSent = result.entriesSent ?? 0;305 setState({306 status: failed ? "error" : "idle",307 outcome: result.outcome,308 detail: result.detail,309 error: failed ? result.detail ?? result.outcome : undefined,310 finishedAt: now,311 entriesReceived,312 entriesSent,313 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"314 ? { lastSyncAt: now }315 : {}),316 });317 log(318 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +319 (entriesReceived + entriesSent > 0320 ? ` (sent ${entriesSent}, received ${entriesReceived})`321 : ""),322 );323 void setLastSyncResult({324 outcome: result.outcome,325 detail: result.detail,326 at: now,327 entriesReceived,328 entriesSent,329 }).catch(() => undefined);330}331332333function inventory(devices: PairedDevice[]): string {334 return devices.map(describeHost).join("; ");335}336337function describeHost(d: PairedDevice): string {338 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;339}340341342343344345function log(message: string): void {346 console.info(`[Peach sync] ${message}`);347}348349function setState(patch: Partial<SyncState>): void {350 const state = getSyncTriggerState();351 state.lastState = { ...state.lastState, ...patch };352 broadcast(state.lastState);353}354355function broadcast(state: SyncState): void {356 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;357 try {358 const maybeThenable = chrome.runtime.sendMessage({359 type: "SYNC_STATE_CHANGED",360 payload: state,361 });362 if (363 maybeThenable &&364 typeof (maybeThenable as Promise<unknown>).catch === "function"365 ) {366 (maybeThenable as Promise<unknown>).catch(() => {367 368 });369 }370 } catch {371 372 }373}