sync-settings.ts

4.9 KB · raw

1import { DEFAULT_RELAY_URL, normalizeRelayUrl } from "../shared/rendezvous";2import { setRelayUrlForAllPairedDevices } from "./paired-devices";34/**5 * Sync is exactly three named choices (docs/sync-lan-retirement.md):6 *7 *  - `public`  — Peach's relay. The default, zero setup.8 *  - `private` — a self-hosted peach-relay the user points their devices at.9 *  - `off`     — no sync; the vault moves between devices by Codex/CSV export.10 *11 * LAN peer-to-peer sync is retired, not deprecated: there is no fourth mode and12 * no "local network only" opt-out. `public` and `private` differ only in which13 * relay URL is in force, so the transport below them is identical.14 */15export type SyncMode = "public" | "private" | "off";1617export interface SyncSettings {18  mode: SyncMode;19  /**20   * The relay in force. Always `DEFAULT_RELAY_URL` under `public`; the user's21   * URL under `private`. Retained (not blanked) under `off` so turning sync22   * back on does not lose a configured private relay.23   */24  relayUrl: string;25}2627const RELAY_URL_KEY = "peach_relay_url_v1";28const SYNC_MODE_KEY = "peach_sync_mode_v1";29/** Retired `lanOnly` flag, read once for migration and never written again. */30const LEGACY_LAN_ONLY_KEY = "peach_sync_lan_only_v1";3132export async function getSyncSettings(): Promise<SyncSettings> {33  try {34    const values = (await chrome.storage.local.get([35      RELAY_URL_KEY,36      SYNC_MODE_KEY,37      LEGACY_LAN_ONLY_KEY,38    ])) as Record<string, unknown>;3940    const storedUrl =41      typeof values[RELAY_URL_KEY] === "string"42        ? safeNormalize(values[RELAY_URL_KEY] as string)43        : DEFAULT_RELAY_URL;4445    const stored = values[SYNC_MODE_KEY];46    if (stored === "public" || stored === "private" || stored === "off") {47      return { mode: stored, relayUrl: relayFor(stored, storedUrl) };48    }4950    // Migration. Someone who set lanOnly chose "no relay involved", and LAN is51    // now gone, so the honest landing spot is `off` rather than silently52    // enrolling them in a relay they opted out of. Everyone else lands on53    // public/private by whether they had configured a custom URL.54    const mode: SyncMode =55      values[LEGACY_LAN_ONLY_KEY] === true56        ? "off"57        : storedUrl === DEFAULT_RELAY_URL58          ? "public"59          : "private";60    return { mode, relayUrl: relayFor(mode, storedUrl) };61  } catch {62    return { mode: "public", relayUrl: DEFAULT_RELAY_URL };63  }64}6566/**67 * `private` chosen but no relay entered yet. The stored URL still reads as the68 * public default, so running sync here would quietly put the user on Peach's69 * relay under a "Private relay" label — the exact trap the retired `lanOnly`70 * flag set. Every sync entry point fails closed on this instead.71 */72export function isPrivateRelayUnconfigured(settings: SyncSettings): boolean {73  return settings.mode === "private" && settings.relayUrl === DEFAULT_RELAY_URL;74}7576/**77 * The relay to dial, or a reason not to. Single gate for every sync and pairing78 * entry point, so a new caller cannot forget one of the two ways sync can be79 * unusable.80 */81export function relayInForce(settings: SyncSettings): string {82  if (settings.mode === "off") throw new Error("sync_disabled");83  if (isPrivateRelayUnconfigured(settings)) throw new Error("private_relay_unset");84  return settings.relayUrl;85}8687export async function setSyncMode(mode: SyncMode): Promise<SyncSettings> {88  const current = await getSyncSettings();89  const next: SyncSettings = { mode, relayUrl: relayFor(mode, current.relayUrl) };90  // The relay is mesh state: every paired device must agree on it, or a peer91  // keeps dialling a relay we no longer use. Only propagate one we will92  // actually use — switching to `private` before a URL is typed must not push93  // the public relay back out to the mesh.94  if (mode !== "off" && !isPrivateRelayUnconfigured(next)) {95    await setRelayUrlForAllPairedDevices(next.relayUrl);96  }97  await chrome.storage.local.set({ [SYNC_MODE_KEY]: mode, [RELAY_URL_KEY]: next.relayUrl });98  return next;99}100101/**102 * Point this mesh at a relay by URL. A custom URL implies `private` — offering103 * one and not switching to it would be a trap. Peach's own relay stays104 * `public`: adoption hands the source's relay over this same path, so calling105 * that "private" would mislabel every device that joined over the public relay106 * and strand it in the unconfigured-private state above.107 */108export async function setRelayUrl(input: string): Promise<SyncSettings> {109  const relayUrl = normalizeRelayUrl(input);110  const mode: SyncMode = relayUrl === DEFAULT_RELAY_URL ? "public" : "private";111  await setRelayUrlForAllPairedDevices(relayUrl);112  await chrome.storage.local.set({ [RELAY_URL_KEY]: relayUrl, [SYNC_MODE_KEY]: mode });113  return { mode, relayUrl };114}115116function relayFor(mode: SyncMode, configured: string): string {117  return mode === "public" ? DEFAULT_RELAY_URL : configured;118}119120function safeNormalize(input: string): string {121  try {122    return normalizeRelayUrl(input);123  } catch {124    return DEFAULT_RELAY_URL;125  }126}