1234567export type SyncOutcome =8 | "completed_changes"9 | "completed_no_changes"10 | "no_paired_devices"11 | "no_eligible_host"12 | "waiting_for_peer"13 | "runner_unavailable"14 | "sync_disabled"15 | "private_relay_unset"16 | "vault_locked"17 | "relay_unreachable"18 | "handshake_failed"19 | "vault_id_mismatch"20 | "peer_protocol_outdated"21 | "reset_required"22 | "sync_generation_unknown"23 | "sync_timeout"24 | "connection_lost"25 | "trust_data_corrupted"26 | "run_failed";2728293031323334353637const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([38 "no_eligible_host",39 "runner_unavailable",40 "private_relay_unset",41 "vault_id_mismatch",42 "peer_protocol_outdated",43 44 45 "reset_required",46 "sync_generation_unknown",47 48 49 "trust_data_corrupted",50 "run_failed",51]);525354555657const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([58 "relay_unreachable",59 "handshake_failed",60 "sync_timeout",61 "connection_lost",62 63 64 65 66 "vault_locked",67]);68697071727374757677787980export function isConnectionClosedError(err: unknown): boolean {81 const message = err instanceof Error ? err.message : String(err);82 return /websocket closed|connection closed/i.test(message);83}8485export function isSyncFailure(outcome: SyncOutcome): boolean {86 return FAILURES.has(outcome);87}8889export function isSyncWarning(outcome: SyncOutcome): boolean {90 return WARNINGS.has(outcome);91}9293export interface SyncOutcomeSummary {94 status?: "idle" | "syncing" | "error";95 outcome?: SyncOutcome;96 entriesSent?: number;97 entriesReceived?: number;98 detail?: string;99 100 finishedAt?: number;101}102103104105106107108109110111export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";112113export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {114 if (s.status === "syncing") return "busy";115 if (s.outcome && isSyncFailure(s.outcome)) return "error";116 if (s.outcome && isSyncWarning(s.outcome)) return "warn";117 if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {118 return "ok";119 }120 if (s.outcome === "waiting_for_peer") return "ok";121 122 123 if (pairedCount > 0 && s.outcome === undefined) return "ok";124 return "idle";125}126127const INDICATOR_LABEL: Record<SyncIndicator, string> = {128 busy: "Syncing",129 ok: "Sync healthy",130 warn: "Last sync attempt missed — will retry",131 error: "Sync problem",132 idle: "Not syncing",133};134135136export function syncIndicatorLabel(137 indicator: SyncIndicator,138 s: SyncOutcomeSummary,139): string {140 return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;141}142143const COPY: Record<SyncOutcome, string> = {144 completed_changes: "Synced",145 completed_no_changes: "Up to date — nothing to send or receive",146 no_paired_devices: "No devices paired yet",147 no_eligible_host: "Paired, but no device here can accept a sync",148 waiting_for_peer: "Waiting for the paired device to connect",149 runner_unavailable: "Sync isn't running in the background — reload Peach",150 sync_disabled: "Sync is switched off",151 private_relay_unset: "Private relay chosen but no address set",152 vault_locked: "Vault was locked — unlock and sync again",153 relay_unreachable: "Couldn't reach the relay",154 handshake_failed: "Reached the relay, but the device didn't answer",155 vault_id_mismatch: "That device holds a different vault",156 peer_protocol_outdated: "Update Peach on both devices, then re-pair them",157 reset_required:158 "Sync is reset on this device — update Peach everywhere, then re-pair each device",159 sync_generation_unknown: "This device's sync generation is unreadable — sync is stopped",160 sync_timeout: "The device stopped responding mid-sync",161 connection_lost: "The connection dropped mid-sync — will retry",162 trust_data_corrupted: "Device trust data is damaged — re-pair your devices after updating",163 run_failed: "Sync failed",164};165166167168169170171172export function describeSyncOutcome(s: SyncOutcomeSummary): string {173 if (!s.outcome) return "Not synced yet";174 const base = COPY[s.outcome] ?? "Sync failed";175 if (s.outcome === "completed_changes") {176 const moved = [177 (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,178 (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,179 ].filter(Boolean);180 return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;181 }182 return s.detail ? `${base} (${s.detail})` : base;183}