// Why a sync run ended, in a form both the service worker and the popup can // name. Before this existed, "no eligible host", "runner not wired" and // "synced fine, nothing to do" were all a bare `return` — indistinguishable // to the user and to us. A failed sync and a successful no-op must never // look the same again. export type SyncOutcome = | "completed_changes" | "completed_no_changes" | "no_paired_devices" | "no_eligible_host" | "waiting_for_peer" | "runner_unavailable" | "sync_disabled" | "private_relay_unset" | "vault_locked" | "relay_unreachable" | "handshake_failed" | "vault_id_mismatch" | "peer_protocol_outdated" | "sync_timeout" | "connection_lost" | "run_failed"; /** * Outcomes where sync did not do what the user asked, so the UI shows red. * * Network-class failures (relay/handshake/timeout) are NOT in this set — * with a 10-minute background alarm the normal state for most minutes is * "the relay didn't answer this time". That is the network, not the app, * and the next alarm tick will retry. They live in `WARNINGS` so the * indicator goes yellow instead of red. */ const FAILURES: ReadonlySet = new Set([ "no_eligible_host", "runner_unavailable", "private_relay_unset", "vault_id_mismatch", "peer_protocol_outdated", "run_failed", ]); /** * Network-class outcomes — the dial did not reach the peer this attempt. * The alarm keeps trying, so this is warn (yellow), not error (red). */ const WARNINGS: ReadonlySet = new Set([ "relay_unreachable", "handshake_failed", "sync_timeout", "connection_lost", // vault_locked is the reader's vault being closed — not a misconfiguration, // not a broken sync. The user is in another app or hasn't unlocked yet; the // next unlock + retry will succeed. Same alarm-keeps-trying semantics as a // missed dial, so it lives here rather than in FAILURES. "vault_locked", ]); /** * A transport that hung up, as opposed to a protocol or vault problem. * * Lives here because two callers need the same judgement and must not drift: * the coordinator (a close while waiting for the peer's final SYNC_DONE echo * is a completed sync) and the trigger's error classifier (a close anywhere * else is network-class, so yellow rather than red). * * Deliberately does not match SyncFrameTooLargeError's "WebSocket close 1009" * — an oversized frame is a misbehaving peer and must keep failing. */ export function isConnectionClosedError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return /websocket closed|connection closed/i.test(message); } export function isSyncFailure(outcome: SyncOutcome): boolean { return FAILURES.has(outcome); } export function isSyncWarning(outcome: SyncOutcome): boolean { return WARNINGS.has(outcome); } export interface SyncOutcomeSummary { status?: "idle" | "syncing" | "error"; outcome?: SyncOutcome; entriesSent?: number; entriesReceived?: number; detail?: string; /** When the last attempt ended, success or not. */ finishedAt?: number; } /** * The one indicator light. `busy` pulses green, `ok` is solid green, `warn` * is yellow (a transient network miss — the next alarm tick retries), * `error` is red (the app is actually broken), `idle` is unlit. Nothing * that is actually broken may resolve to `ok` — a paired-but-unsyncable * device is red, not green. A dial that didn't reach the peer is yellow, * because the alarm will try again. */ export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle"; export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator { if (s.status === "syncing") return "busy"; if (s.outcome && isSyncFailure(s.outcome)) return "error"; if (s.outcome && isSyncWarning(s.outcome)) return "warn"; if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") { return "ok"; } if (s.outcome === "waiting_for_peer") return "ok"; // Paired with a healthy history but no attempt since this SW woke: still a // successful pair, which is what the light is claiming. if (pairedCount > 0 && s.outcome === undefined) return "ok"; return "idle"; } const INDICATOR_LABEL: Record = { busy: "Syncing", ok: "Sync healthy", warn: "Last sync attempt missed — will retry", error: "Sync problem", idle: "Not syncing", }; /** Colour alone is never the signal — this is the same claim in words. */ export function syncIndicatorLabel( indicator: SyncIndicator, s: SyncOutcomeSummary, ): string { return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`; } const COPY: Record = { completed_changes: "Synced", completed_no_changes: "Up to date — nothing to send or receive", no_paired_devices: "No devices paired yet", no_eligible_host: "Paired, but no device here can accept a sync", waiting_for_peer: "Waiting for the paired device to connect", runner_unavailable: "Sync isn't running in the background — reload Peach", sync_disabled: "Sync is switched off", private_relay_unset: "Private relay chosen but no address set", vault_locked: "Vault was locked — unlock and sync again", relay_unreachable: "Couldn't reach the relay", handshake_failed: "Reached the relay, but the device didn't answer", vault_id_mismatch: "That device holds a different vault", peer_protocol_outdated: "Update Peach on both devices, then re-pair them", sync_timeout: "The device stopped responding mid-sync", connection_lost: "The connection dropped mid-sync — will retry", run_failed: "Sync failed", }; /** * One plain-English line for a sync outcome. `detail` — the raw internal code * or the device inventory behind `no_eligible_host` — is appended rather than * hidden: it is the whole diagnostic on a real device, where devtools is not * an option. */ export function describeSyncOutcome(s: SyncOutcomeSummary): string { if (!s.outcome) return "Not synced yet"; const base = COPY[s.outcome] ?? "Sync failed"; if (s.outcome === "completed_changes") { const moved = [ (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null, (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null, ].filter(Boolean); return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base; } return s.detail ? `${base} (${s.detail})` : base; }