sync-alarm.ts

3.8 KB · raw

1// sync-alarm.ts — the 10-minute background sync tick.2//3// Five triggers, in order of reliability:4//   1. this alarm (survives MV3 service-worker death — only path that does)5//   2. SW activation hook in entry.ts (startup reason — first dial without6//      waiting for the alarm)7//   3. device_list_changed on pairing/unpair (paired-devices.ts)8//   4. vault_change on every committed write (vault.ts fireSyncTrigger)9//   5. post-adoption one-shot (schedulePostAdoptionSync) — bridges the gap10//      between a failed inline snapshot and the next 10-minute tick11//12// All four call the same `triggerSyncIfReachable`. The in-flight guard there13// ensures a slow sync is never preempted by an alarm firing while it runs.1415import { triggerSyncIfReachable } from "./sync-trigger";1617/**18 * Single alarm name. Shared with the listener below — change together.19 * The `peach-` prefix keeps this distinct from any third-party alarm a20 * future extension may install.21 */22export const SYNC_ALARM_NAME = "peach-sync-periodic";2324/**25 * Chrome's recurring-alarm floor is 1 minute — anything shorter is treated26 * as a one-shot. 10 is well above that and the longest the user is likely27 * to wait for changes to propagate between paired devices without manual28 * action. Lowering this (e.g. for testing) must keep it >= 1.29 */30export const SYNC_ALARM_PERIOD_MINUTES = 10;3132/**33 * One-shot alarm prefix for post-adoption sync retries. Unlike the periodic34 * alarm, these are fire-once: Chrome clears them after they fire. The prefix35 * lets the listener match any retry without a fixed name.36 */37const POST_ADOPTION_ALARM_PREFIX = "peach-post-adoption-";3839let alarmListenerInstalled = false;4041/**42 * Schedule the recurring sync alarm. Idempotent — calling twice does not43 * stack two alarms. Uses `periodInMinutes` (Chrome's own repeat primitive)44 * rather than a manual reschedule loop, so a worker kill between fires does45 * not interrupt the schedule — Chrome holds the next-fire time.46 */47export function setupSyncAlarm(): void {48  if (!chrome.alarms?.create || !chrome.alarms?.clear) return;49  if (!alarmListenerInstalled) {50    // Static, not `await import()`: runtime import() throws in an MV3 service51    // worker (scripts/check-sw-static-imports.mjs pins this). triggerSyncIfReachable52    // is top-level-imported via sync-trigger, which itself statically imports53    // its deps — no cycle, no dynamic import.54    chrome.alarms.onAlarm.addListener((alarm) => {55      if (alarm.name === SYNC_ALARM_NAME) {56        void triggerSyncIfReachable({ reason: "alarm" }).catch(() => undefined);57      } else if (alarm.name.startsWith(POST_ADOPTION_ALARM_PREFIX)) {58        void triggerSyncIfReachable({ reason: "alarm", force: true }).catch(() => undefined);59      }60    });61    alarmListenerInstalled = true;62  }63  // Clear-then-create so a changed period (config, future toggle) takes effect64  // without leaving a stale one-shot behind.65  chrome.alarms.clear(SYNC_ALARM_NAME);66  chrome.alarms.create(SYNC_ALARM_NAME, {67    periodInMinutes: SYNC_ALARM_PERIOD_MINUTES,68  });69}7071/**72 * Test seam. Resets the one-shot listener-install flag so a fresh73 * `setupSyncAlarm()` call re-registers against the (new) chrome mock.74 */75export function _resetSyncAlarmForTest(): void {76  alarmListenerInstalled = false;77}7879/**80 * Schedule one-shot sync retries after a failed inline snapshot. Uses81 * chrome.alarms (survives SW death) instead of setTimeout (does not).82 * Two retries: 30s and 2min. The 10-minute periodic alarm is the long-term83 * backstop. Chrome's minimum alarm resolution is ~30s in stable.84 */85export function schedulePostAdoptionSync(): void {86  if (!chrome.alarms?.create) return;87  const now = Date.now();88  chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-1`, {89    delayInMinutes: 0.5,90  });91  chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-2`, {92    delayInMinutes: 2,93  });94}