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}117118119120121122123124export async function hydratePersistedSyncState(): Promise<void> {125 const state = getSyncTriggerState();126 if (state.lastState.lastSyncAt === undefined) {127 const persisted = await getLastSyncAt();128 if (typeof persisted === "number") {129 state.lastState = { ...state.lastState, lastSyncAt: persisted };130 }131 }132 133 134 135 if (state.lastState.outcome === undefined) {136 const last = await getLastSyncResult();137 if (last) {138 state.lastState = {139 ...state.lastState,140 outcome: last.outcome,141 detail: last.detail,142 finishedAt: last.at,143 entriesReceived: last.entriesReceived,144 entriesSent: last.entriesSent,145 };146 }147 }148}149export async function triggerSyncIfReachable(opts: {150 reason: SyncTriggerReason;151 force?: boolean;152}): Promise<void> {153 if (vault.isDecoyMode()) return;154 const state = getSyncTriggerState();155 156 157 158 if (state.syncInFlight && !opts.force) {159 160 log(`trigger (${opts.reason}) ignored, a sync is already in flight`);161 return;162 }163 state.syncInFlight = true;164165 try {166 167 168 169 const decision = await assertSyncAllowed(browserGenerationIo);170 if (decision !== "allowed") {171 finish({ outcome: decision });172 return;173 }174 let devices: PairedDevice[];175 try {176 devices = await listPairedDevices();177 } catch (err) {178 if (err instanceof TrustDataCorruptedError) {179 finish({ outcome: "trust_data_corrupted", detail: err.message });180 return;181 }182 throw err;183 }184 const routeCapable = devices.filter(185 (d) =>186 (187 d.deviceType === "extension" ||188 d.deviceType === "android" ||189 d.deviceType === "ios"190 ) &&191 (d.trustSource === "direct" || d.trustSource === "vouched"),192 );193 194 195 196 const hosts = state.selectDialTargets197 ? await state.selectDialTargets(routeCapable)198 : routeCapable.filter(199 (d) => d.deviceType === "android" || d.deviceType === "ios",200 );201 if (devices.length === 0) {202 finish({ outcome: "no_paired_devices" });203 return;204 }205 if (hosts.length === 0) {206 if (routeCapable.length > 0 && state.selectDialTargets) {207 finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });208 return;209 }210 211 212 finish({ outcome: "no_eligible_host", detail: inventory(devices) });213 return;214 }215 if (!state.runner) {216 finish({ outcome: "runner_unavailable" });217 return;218 }219220 setState({221 status: "syncing",222 reason: opts.reason,223 error: undefined,224 outcome: undefined,225 detail: undefined,226 });227 log(228 `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,229 );230 await runSyncRounds(hosts);231 } finally {232 state.syncInFlight = false;233 }234}235236async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {237 const state = getSyncTriggerState();238 if (!state.runner) {239 finish({ outcome: "runner_unavailable" });240 return;241 }242243 let entriesReceived = 0;244 let entriesSent = 0;245 let anySucceeded = false;246 let firstError: string | undefined;247248 for (const host of hosts) {249 try {250 const result = await state.runner(host);251 if (!result.ok) {252 log(`host ${describeHost(host)} refused: ${result.error}`);253 if (firstError === undefined) firstError = result.error;254 continue;255 }256 entriesReceived += result.entriesReceived;257 entriesSent += result.entriesSent;258 if (vault.isDecoyMode()) return;259 await markLastSyncedAt(host.deviceId, Date.now());260 if (vault.isDecoyMode()) return;261 anySucceeded = true;262 } catch (err) {263 const message = (err as Error)?.message ?? String(err);264 log(`host ${describeHost(host)} threw: ${message}`);265 if (firstError === undefined) firstError = message;266 }267 }268269 if (vault.isDecoyMode()) return;270 if (anySucceeded) {271 const now = Date.now();272 finish({273 outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",274 275 276 detail: firstError,277 entriesReceived,278 entriesSent,279 });280 281 282 void Promise.resolve()283 .then(() => {284 if (vault.isDecoyMode()) return;285 return setLastSyncAt(now);286 })287 .catch(() => undefined);288 } else {289 const error = firstError ?? "no_host_reachable";290 finish({ outcome: classifySyncError(error), detail: error });291 }292}293294295296297298299function classifySyncError(error: string): SyncOutcome {300 if (error.includes("VAULT_LOCKED")) return "vault_locked";301 if (error.includes("sync_disabled")) return "sync_disabled";302 if (error.includes("private_relay_unset")) return "private_relay_unset";303 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";304 if (error.includes("trust_data_corrupted")) return "trust_data_corrupted";305 if (error.includes("sync_timeout")) return "sync_timeout";306 if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";307 308 309 310 311 312 if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {313 return "connection_lost";314 }315 if (error.startsWith("relay_sync_failed")) {316 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)317 ? "handshake_failed"318 : "relay_unreachable";319 }320 return "run_failed";321}322323interface SyncFinish {324 outcome: SyncOutcome;325 detail?: string;326 entriesReceived?: number;327 entriesSent?: number;328}329330331332333334335function finish(result: SyncFinish): void {336 const now = Date.now();337 const failed = isSyncFailure(result.outcome);338 const entriesReceived = result.entriesReceived ?? 0;339 const entriesSent = result.entriesSent ?? 0;340 setState({341 status: failed ? "error" : "idle",342 outcome: result.outcome,343 detail: result.detail,344 error: failed ? result.detail ?? result.outcome : undefined,345 finishedAt: now,346 entriesReceived,347 entriesSent,348 ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"349 ? { lastSyncAt: now }350 : {}),351 });352 log(353 `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +354 (entriesReceived + entriesSent > 0355 ? ` (sent ${entriesSent}, received ${entriesReceived})`356 : ""),357 );358 void Promise.resolve()359 .then(() => {360 if (vault.isDecoyMode()) return;361 return setLastSyncResult({362 outcome: result.outcome,363 detail: result.detail,364 at: now,365 entriesReceived,366 entriesSent,367 });368 })369 .catch(() => undefined);370}371372373function inventory(devices: PairedDevice[]): string {374 return devices.map(describeHost).join("; ");375}376377function describeHost(d: PairedDevice): string {378 return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;379}380381382383384385function log(message: string): void {386 console.info(`[Peach sync] ${message}`);387}388389function setState(patch: Partial<SyncState>): void {390 const state = getSyncTriggerState();391 state.lastState = { ...state.lastState, ...patch };392 broadcast(state.lastState);393}394395function broadcast(state: SyncState): void {396 if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;397 try {398 const maybeThenable = chrome.runtime.sendMessage({399 type: "SYNC_STATE_CHANGED",400 payload: state,401 });402 if (403 maybeThenable &&404 typeof (maybeThenable as Promise<unknown>).catch === "function"405 ) {406 (maybeThenable as Promise<unknown>).catch(() => {407 408 });409 }410 } catch {411 412 }413}