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 | "run_failed";2627282930313233343536const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([37 "no_eligible_host",38 "runner_unavailable",39 "private_relay_unset",40 "vault_id_mismatch",41 "peer_protocol_outdated",42 43 44 "reset_required",45 "sync_generation_unknown",46 "run_failed",47]);484950515253const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([54 "relay_unreachable",55 "handshake_failed",56 "sync_timeout",57 "connection_lost",58 59 60 61 62 "vault_locked",63]);64656667686970717273747576export function isConnectionClosedError(err: unknown): boolean {77 const message = err instanceof Error ? err.message : String(err);78 return /websocket closed|connection closed/i.test(message);79}8081export function isSyncFailure(outcome: SyncOutcome): boolean {82 return FAILURES.has(outcome);83}8485export function isSyncWarning(outcome: SyncOutcome): boolean {86 return WARNINGS.has(outcome);87}8889export interface SyncOutcomeSummary {90 status?: "idle" | "syncing" | "error";91 outcome?: SyncOutcome;92 entriesSent?: number;93 entriesReceived?: number;94 detail?: string;95 96 finishedAt?: number;97}9899100101102103104105106107export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";108109export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {110 if (s.status === "syncing") return "busy";111 if (s.outcome && isSyncFailure(s.outcome)) return "error";112 if (s.outcome && isSyncWarning(s.outcome)) return "warn";113 if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {114 return "ok";115 }116 if (s.outcome === "waiting_for_peer") return "ok";117 118 119 if (pairedCount > 0 && s.outcome === undefined) return "ok";120 return "idle";121}122123const INDICATOR_LABEL: Record<SyncIndicator, string> = {124 busy: "Syncing",125 ok: "Sync healthy",126 warn: "Last sync attempt missed — will retry",127 error: "Sync problem",128 idle: "Not syncing",129};130131132export function syncIndicatorLabel(133 indicator: SyncIndicator,134 s: SyncOutcomeSummary,135): string {136 return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;137}138139const COPY: Record<SyncOutcome, string> = {140 completed_changes: "Synced",141 completed_no_changes: "Up to date — nothing to send or receive",142 no_paired_devices: "No devices paired yet",143 no_eligible_host: "Paired, but no device here can accept a sync",144 waiting_for_peer: "Waiting for the paired device to connect",145 runner_unavailable: "Sync isn't running in the background — reload Peach",146 sync_disabled: "Sync is switched off",147 private_relay_unset: "Private relay chosen but no address set",148 vault_locked: "Vault was locked — unlock and sync again",149 relay_unreachable: "Couldn't reach the relay",150 handshake_failed: "Reached the relay, but the device didn't answer",151 vault_id_mismatch: "That device holds a different vault",152 peer_protocol_outdated: "Update Peach on both devices, then re-pair them",153 reset_required:154 "Sync is reset on this device — update Peach everywhere, then re-pair each device",155 sync_generation_unknown: "This device's sync generation is unreadable — sync is stopped",156 sync_timeout: "The device stopped responding mid-sync",157 connection_lost: "The connection dropped mid-sync — will retry",158 run_failed: "Sync failed",159};160161162163164165166167export function describeSyncOutcome(s: SyncOutcomeSummary): string {168 if (!s.outcome) return "Not synced yet";169 const base = COPY[s.outcome] ?? "Sync failed";170 if (s.outcome === "completed_changes") {171 const moved = [172 (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,173 (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,174 ].filter(Boolean);175 return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;176 }177 return s.detail ? `${base} (${s.detail})` : base;178}