12345678910111213import * as vault from "./vault";14import { assertSyncAllowed, browserGenerationIo } from "./sync-generation";1516import {17 getLastSyncAt,18 getLastSyncResult,19 listPairedDevices,20 markLastSyncedAt,21 setLastSyncAt,22 setLastSyncResult,23 TrustDataCorruptedError,24} from "./paired-devices";25import type { PairedDevice } from "../shared/sync-types";26import {27 isConnectionClosedError,28 isSyncFailure,29 type SyncOutcome,30} from "../shared/sync-outcome";3132333435363738394041export type SyncTriggerReason =42 | "alarm"43 | "startup"44 | "device_list_changed"45 | "vault_change"46 | "foreground"47 | "manual";4849export type SyncRunStatus = "idle" | "syncing" | "error";5051export interface SyncState {52 status: SyncRunStatus;53 reason?: SyncTriggerReason;54 55 lastSyncAt?: number;56 entriesReceived?: number;57 entriesSent?: number;58 error?: string;59 outcome?: SyncOutcome;60 61 detail?: string;62 finishedAt?: number;63}6465export type SyncRunResult =66 | { ok: true; entriesReceived: number; entriesSent: number }67 | { ok: false; error: string };6869export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;70export type SelectSyncDialTargets = (71 devices: PairedDevice[],72) => Promise<PairedDevice[]>;7374interface PeachSyncTriggerState {75 syncInFlight: boolean;76 lastState: SyncState;77 runner: RunSyncWithDevice | null;78 selectDialTargets: SelectSyncDialTargets | null;79}8081function getSyncTriggerState(): PeachSyncTriggerState {82 const g = globalThis as typeof globalThis & {83 __peachSyncTriggerState?: PeachSyncTriggerState;84 };85 if (!g.__peachSyncTriggerState) {86 g.__peachSyncTriggerState = {87 syncInFlight: false,88 lastState: { status: "idle" },89 runner: null,90 selectDialTargets: null,91 };92 }93 return g.__peachSyncTriggerState;94}9596export function setSyncRunner(fn: RunSyncWithDevice | null): void {97 getSyncTriggerState().runner = fn;98}99100export function setSyncDialTargetSelector(101 fn: SelectSyncDialTargets | null,102): void {103 getSyncTriggerState().selectDialTargets = fn;104}105106export function _resetSyncTriggerForTest(): void {107 const state = getSyncTriggerState();108 state.syncInFlight = false;109 state.lastState = { status: "idle" };110 state.runner = null;111 state.selectDialTargets = null;112}113114export function getSyncState(): SyncState {115 return getSyncTriggerState().lastState;116}117118export function recordInboundSyncCompletion(119 entriesReceived: number,120 entriesSent: number,121): void {122 finish({123 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",124 entriesReceived,125 entriesSent,126 });127}128129130131132133134135export async function hydratePersistedSyncState(): Promise<void> {136 const state = getSyncTriggerState();137 if (state.lastState.lastSyncAt === undefined) {138 const persisted = await getLastSyncAt();139 if (typeof persisted === "number") {140 state.lastState = { ...state.lastState, lastSyncAt: persisted };141 }142 }143 144 145 146 if (state.lastState.outcome === undefined) {147 const last = await getLastSyncResult();148 if (last) {149 state.lastState = {150 ...state.lastState,151 outcome: last.outcome,152 detail: last.detail,153 finishedAt: last.at,154 entriesReceived: last.entriesReceived,155 entriesSent: last.entriesSent,156 };157 }158 }159}160export async function triggerSyncIfReachable(opts: {161 reason: SyncTriggerReason;162 force?: boolean;163}): Promise<void> {164 if (vault.isDecoyMode()) return;165 const state = getSyncTriggerState();166 167 168 169 if (state.syncInFlight && !opts.force) {170 171 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);172 return;173 }174 state.syncInFlight = true;175176 try {177 178 179 180 const decision = await assertSyncAllowed(browserGenerationIo);181 if (decision !== "allowed") {182 finish({ outcome: decision });183 return;184 }185 let devices: PairedDevice[];186 try {187 devices = await listPairedDevices();188 } catch (err) {189 if (err instanceof TrustDataCorruptedError) {190 finish({ outcome: "trust_data_corrupted", detail: err.message });191 return;192 }193 throw err;194 }195 const routeCapable = devices.filter(196 (d) =>197 (198 d.deviceType === "extension" ||199 d.deviceType === "android" ||200 d.deviceType === "ios"201 ) &&202 (d.trustSource === "direct" || d.trustSource === "vouched"),203 );204 205 206 207 const hosts = state.selectDialTargets208 ? await state.selectDialTargets(routeCapable)209 : routeCapable.filter(210 (d) => d.deviceType === "android" || d.deviceType === "ios",211 );212 if (devices.length === 0) {213 finish({ outcome: "no_paired_devices" });214 return;215 }216 if (hosts.length === 0) {217 if (routeCapable.length > 0 && state.selectDialTargets) {218 finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });219 return;220 }221 222 223 finish({ outcome: "no_eligible_host", detail: inventory(devices) });224 return;225 }226 if (!state.runner) {227 finish({ outcome: "runner_unavailable" });228 return;229 }230231 setState({232 status: "syncing",233 reason: opts.reason,234 error: undefined,235 outcome: undefined,236 detail: undefined,237 });238 log(239 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,240 );241 await runSyncRounds(hosts);242 } finally {243 state.syncInFlight = false;244 }245}246247async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {248 const state = getSyncTriggerState();249 if (!state.runner) {250 finish({ outcome: "runner_unavailable" });251 return;252 }253254 let entriesReceived = 0;255 let entriesSent = 0;256 let anySucceeded = false;257 let firstError: string | undefined;258259 for (const host of hosts) {260 try {261 const result = await state.runner(host);262 if (!result.ok) {263 log(`host ${describeHost(host)} refused: ${result.error}`);264 if (firstError === undefined) firstError = result.error;265 continue;266 }267 entriesReceived += result.entriesReceived;268 entriesSent += result.entriesSent;269 if (vault.isDecoyMode()) return;270 await markLastSyncedAt(host.deviceId, Date.now());271 if (vault.isDecoyMode()) return;272 anySucceeded = true;273 } catch (err) {274 const message = (err as Error)?.message ?? String(err);275 log(`host ${describeHost(host)} threw: ${message}`);276 if (firstError === undefined) firstError = message;277 }278 }279280 if (vault.isDecoyMode()) return;281 if (anySucceeded) {282 finish({283 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",284 285 286 detail: firstError,287 entriesReceived,288 entriesSent,289 });290 } else {291 const error = firstError ?? "no_host_reachable";292 finish({ outcome: classifySyncError(error), detail: error });293 }294}295296297298299300301function classifySyncError(error: string): SyncOutcome {302 if (error.includes("VAULT_LOCKED")) return "vault_locked";303 if (error.includes("sync_disabled")) return "sync_disabled";304 if (error.includes("private_relay_unset")) return "private_relay_unset";305 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";306 if (error.includes("trust_data_corrupted")) return "trust_data_corrupted";307 if (error.includes("sync_timeout")) return "sync_timeout";308 if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";309 310 311 312 313 314 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {315 return "connection_lost";316 }317 if (error.startsWith("relay_sync_failed")) {318 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)319 ? "handshake_failed"320 : "relay_unreachable";321 }322 return "run_failed";323}324325interface SyncFinish {326 outcome: SyncOutcome;327 detail?: string;328 entriesReceived?: number;329 entriesSent?: number;330}331332333334335336337function finish(result: SyncFinish): void {338 const now = Date.now();339 const failed = isSyncFailure(result.outcome);340 const entriesReceived = result.entriesReceived ?? 0;341 const entriesSent = result.entriesSent ?? 0;342 setState({343 status: failed ? "error" : "idle",344 outcome: result.outcome,345 detail: result.detail,346 error: failed ? result.detail ?? result.outcome : undefined,347 finishedAt: now,348 entriesReceived,349 entriesSent,350 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"351 ? { lastSyncAt: now }352 : {}),353 });354 log(355 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +356 (entriesReceived + entriesSent > 0357 ? ` (sent ${entriesSent}, received ${entriesReceived})`358 : ""),359 );360 void Promise.resolve()361 .then(() => {362 if (vault.isDecoyMode()) return;363 const persisted = setLastSyncResult({364 outcome: result.outcome,365 detail: result.detail,366 at: now,367 entriesReceived,368 entriesSent,369 });370 if (result.outcome === "completed_changes" || result.outcome === "completed_no_changes") {371 return Promise.all([persisted, setLastSyncAt(now)]).then(() => undefined);372 }373 return persisted;374 })375 .catch(() => undefined);376}377378379function inventory(devices: PairedDevice[]): string {380 return devices.map(describeHost).join("; ");381}382383function describeHost(d: PairedDevice): string {384 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;385}386387388389390391function log(message: string): void {392 console.info(`[Peach sync] ${message}`);393}394395function setState(patch: Partial<SyncState>): void {396 const state = getSyncTriggerState();397 state.lastState = { ...state.lastState, ...patch };398 broadcast(state.lastState);399}400401function broadcast(state: SyncState): void {402 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;403 try {404 const maybeThenable = chrome.runtime.sendMessage({405 type: "SYNC_STATE_CHANGED",406 payload: state,407 });408 if (409 maybeThenable &&410 typeof (maybeThenable as Promise<unknown>).catch === "function"411 ) {412 (maybeThenable as Promise<unknown>).catch(() => {413 414 });415 }416 } catch {417 418 }419}