12345678910111213import * as vault from "./vault";1415import {16 getLastSyncAt,17 getLastSyncResult,18 listPairedDevices,19 markLastSyncedAt,20 setLastSyncAt,21 setLastSyncResult,22} from "./paired-devices";23import type { PairedDevice } from "../shared/sync-types";24import {25 isConnectionClosedError,26 isSyncFailure,27 type SyncOutcome,28} from "../shared/sync-outcome";2930313233343536373839export type SyncTriggerReason =40 | "alarm"41 | "startup"42 | "device_list_changed"43 | "vault_change"44 | "foreground"45 | "manual";4647export type SyncRunStatus = "idle" | "syncing" | "error";4849export interface SyncState {50 status: SyncRunStatus;51 reason?: SyncTriggerReason;52 53 lastSyncAt?: number;54 entriesReceived?: number;55 entriesSent?: number;56 error?: string;57 outcome?: SyncOutcome;58 59 detail?: string;60 finishedAt?: number;61}6263export type SyncRunResult =64 | { ok: true; entriesReceived: number; entriesSent: number }65 | { ok: false; error: string };6667export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;68export type SelectSyncDialTargets = (69 devices: PairedDevice[],70) => Promise<PairedDevice[]>;7172interface PeachSyncTriggerState {73 syncInFlight: boolean;74 lastState: SyncState;75 runner: RunSyncWithDevice | null;76 selectDialTargets: SelectSyncDialTargets | null;77}7879function getSyncTriggerState(): PeachSyncTriggerState {80 const g = globalThis as typeof globalThis & {81 __peachSyncTriggerState?: PeachSyncTriggerState;82 };83 if (!g.__peachSyncTriggerState) {84 g.__peachSyncTriggerState = {85 syncInFlight: false,86 lastState: { status: "idle" },87 runner: null,88 selectDialTargets: null,89 };90 }91 return g.__peachSyncTriggerState;92}9394export function setSyncRunner(fn: RunSyncWithDevice | null): void {95 getSyncTriggerState().runner = fn;96}9798export function setSyncDialTargetSelector(99 fn: SelectSyncDialTargets | null,100): void {101 getSyncTriggerState().selectDialTargets = fn;102}103104export function _resetSyncTriggerForTest(): void {105 const state = getSyncTriggerState();106 state.syncInFlight = false;107 state.lastState = { status: "idle" };108 state.runner = null;109 state.selectDialTargets = null;110}111112export function getSyncState(): SyncState {113 return getSyncTriggerState().lastState;114}115116117118119120121122export async function hydratePersistedSyncState(): Promise<void> {123 const state = getSyncTriggerState();124 if (state.lastState.lastSyncAt === undefined) {125 const persisted = await getLastSyncAt();126 if (typeof persisted === "number") {127 state.lastState = { ...state.lastState, lastSyncAt: persisted };128 }129 }130 131 132 133 if (state.lastState.outcome === undefined) {134 const last = await getLastSyncResult();135 if (last) {136 state.lastState = {137 ...state.lastState,138 outcome: last.outcome,139 detail: last.detail,140 finishedAt: last.at,141 entriesReceived: last.entriesReceived,142 entriesSent: last.entriesSent,143 };144 }145 }146}147export async function triggerSyncIfReachable(opts: {148 reason: SyncTriggerReason;149 force?: boolean;150}): Promise<void> {151 if (vault.isDecoyMode()) return;152 const state = getSyncTriggerState();153 154 155 156 if (state.syncInFlight && !opts.force) {157 158 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);159 return;160 }161 state.syncInFlight = true;162163 try {164 const devices = await listPairedDevices();165 const routeCapable = devices.filter(166 (d) =>167 (168 d.deviceType === "extension" ||169 d.deviceType === "android" ||170 d.deviceType === "ios"171 ) &&172 (d.trustSource === "direct" || d.trustSource === "vouched"),173 );174 175 176 177 const hosts = state.selectDialTargets178 ? await state.selectDialTargets(routeCapable)179 : routeCapable.filter(180 (d) => d.deviceType === "android" || d.deviceType === "ios",181 );182 if (devices.length === 0) {183 finish({ outcome: "no_paired_devices" });184 return;185 }186 if (hosts.length === 0) {187 if (routeCapable.length > 0 && state.selectDialTargets) {188 finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });189 return;190 }191 192 193 finish({ outcome: "no_eligible_host", detail: inventory(devices) });194 return;195 }196 if (!state.runner) {197 finish({ outcome: "runner_unavailable" });198 return;199 }200201 setState({202 status: "syncing",203 reason: opts.reason,204 error: undefined,205 outcome: undefined,206 detail: undefined,207 });208 log(209 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,210 );211 await runSyncRounds(hosts);212 } finally {213 state.syncInFlight = false;214 }215}216217async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {218 const state = getSyncTriggerState();219 if (!state.runner) {220 finish({ outcome: "runner_unavailable" });221 return;222 }223224 let entriesReceived = 0;225 let entriesSent = 0;226 let anySucceeded = false;227 let firstError: string | undefined;228229 for (const host of hosts) {230 try {231 const result = await state.runner(host);232 if (!result.ok) {233 log(`host ${describeHost(host)} refused: ${result.error}`);234 if (firstError === undefined) firstError = result.error;235 continue;236 }237 entriesReceived += result.entriesReceived;238 entriesSent += result.entriesSent;239 if (vault.isDecoyMode()) return;240 await markLastSyncedAt(host.deviceId, Date.now());241 if (vault.isDecoyMode()) return;242 anySucceeded = true;243 } catch (err) {244 const message = (err as Error)?.message ?? String(err);245 log(`host ${describeHost(host)} threw: ${message}`);246 if (firstError === undefined) firstError = message;247 }248 }249250 if (vault.isDecoyMode()) return;251 if (anySucceeded) {252 const now = Date.now();253 finish({254 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",255 256 257 detail: firstError,258 entriesReceived,259 entriesSent,260 });261 262 263 void Promise.resolve()264 .then(() => {265 if (vault.isDecoyMode()) return;266 return setLastSyncAt(now);267 })268 .catch(() => undefined);269 } else {270 const error = firstError ?? "no_host_reachable";271 finish({ outcome: classifySyncError(error), detail: error });272 }273}274275276277278279280function classifySyncError(error: string): SyncOutcome {281 if (error.includes("VAULT_LOCKED")) return "vault_locked";282 if (error.includes("sync_disabled")) return "sync_disabled";283 if (error.includes("private_relay_unset")) return "private_relay_unset";284 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";285 if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";286 if (error.includes("sync_timeout")) return "sync_timeout";287 288 289 290 291 292 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {293 return "connection_lost";294 }295 if (error.startsWith("relay_sync_failed")) {296 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)297 ? "handshake_failed"298 : "relay_unreachable";299 }300 return "run_failed";301}302303interface SyncFinish {304 outcome: SyncOutcome;305 detail?: string;306 entriesReceived?: number;307 entriesSent?: number;308}309310311312313314315function finish(result: SyncFinish): void {316 const now = Date.now();317 const failed = isSyncFailure(result.outcome);318 const entriesReceived = result.entriesReceived ?? 0;319 const entriesSent = result.entriesSent ?? 0;320 setState({321 status: failed ? "error" : "idle",322 outcome: result.outcome,323 detail: result.detail,324 error: failed ? result.detail ?? result.outcome : undefined,325 finishedAt: now,326 entriesReceived,327 entriesSent,328 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"329 ? { lastSyncAt: now }330 : {}),331 });332 log(333 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +334 (entriesReceived + entriesSent > 0335 ? ` (sent ${entriesSent}, received ${entriesReceived})`336 : ""),337 );338 void Promise.resolve()339 .then(() => {340 if (vault.isDecoyMode()) return;341 return setLastSyncResult({342 outcome: result.outcome,343 detail: result.detail,344 at: now,345 entriesReceived,346 entriesSent,347 });348 })349 .catch(() => undefined);350}351352353function inventory(devices: PairedDevice[]): string {354 return devices.map(describeHost).join("; ");355}356357function describeHost(d: PairedDevice): string {358 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;359}360361362363364365function log(message: string): void {366 console.info(`[Peach sync] ${message}`);367}368369function setState(patch: Partial<SyncState>): void {370 const state = getSyncTriggerState();371 state.lastState = { ...state.lastState, ...patch };372 broadcast(state.lastState);373}374375function broadcast(state: SyncState): void {376 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;377 try {378 const maybeThenable = chrome.runtime.sendMessage({379 type: "SYNC_STATE_CHANGED",380 payload: state,381 });382 if (383 maybeThenable &&384 typeof (maybeThenable as Promise<unknown>).catch === "function"385 ) {386 (maybeThenable as Promise<unknown>).catch(() => {387 388 });389 }390 } catch {391 392 }393}