// sync-alarm.ts — the 10-minute background sync tick. // // Five triggers, in order of reliability: // 1. this alarm (survives MV3 service-worker death — only path that does) // 2. SW activation hook in entry.ts (startup reason — first dial without // waiting for the alarm) // 3. device_list_changed on pairing/unpair (paired-devices.ts) // 4. vault_change on every committed write (vault.ts fireSyncTrigger) // 5. post-adoption one-shot (schedulePostAdoptionSync) — bridges the gap // between a failed inline snapshot and the next 10-minute tick // // All four call the same `triggerSyncIfReachable`. The in-flight guard there // ensures a slow sync is never preempted by an alarm firing while it runs. import { triggerSyncIfReachable } from "./sync-trigger"; /** * Single alarm name. Shared with the listener below — change together. * The `peach-` prefix keeps this distinct from any third-party alarm a * future extension may install. */ export const SYNC_ALARM_NAME = "peach-sync-periodic"; /** * Chrome's recurring-alarm floor is 1 minute — anything shorter is treated * as a one-shot. 10 is well above that and the longest the user is likely * to wait for changes to propagate between paired devices without manual * action. Lowering this (e.g. for testing) must keep it >= 1. */ export const SYNC_ALARM_PERIOD_MINUTES = 10; /** * One-shot alarm prefix for post-adoption sync retries. Unlike the periodic * alarm, these are fire-once: Chrome clears them after they fire. The prefix * lets the listener match any retry without a fixed name. */ const POST_ADOPTION_ALARM_PREFIX = "peach-post-adoption-"; let alarmListenerInstalled = false; /** * Schedule the recurring sync alarm. Idempotent — calling twice does not * stack two alarms. Uses `periodInMinutes` (Chrome's own repeat primitive) * rather than a manual reschedule loop, so a worker kill between fires does * not interrupt the schedule — Chrome holds the next-fire time. */ export function setupSyncAlarm(): void { if (!chrome.alarms?.create || !chrome.alarms?.clear) return; if (!alarmListenerInstalled) { // Static, not `await import()`: runtime import() throws in an MV3 service // worker (scripts/check-sw-static-imports.mjs pins this). triggerSyncIfReachable // is top-level-imported via sync-trigger, which itself statically imports // its deps — no cycle, no dynamic import. chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === SYNC_ALARM_NAME) { void triggerSyncIfReachable({ reason: "alarm" }).catch(() => undefined); } else if (alarm.name.startsWith(POST_ADOPTION_ALARM_PREFIX)) { void triggerSyncIfReachable({ reason: "alarm", force: true }).catch(() => undefined); } }); alarmListenerInstalled = true; } // Clear-then-create so a changed period (config, future toggle) takes effect // without leaving a stale one-shot behind. chrome.alarms.clear(SYNC_ALARM_NAME); chrome.alarms.create(SYNC_ALARM_NAME, { periodInMinutes: SYNC_ALARM_PERIOD_MINUTES, }); } /** * Test seam. Resets the one-shot listener-install flag so a fresh * `setupSyncAlarm()` call re-registers against the (new) chrome mock. */ export function _resetSyncAlarmForTest(): void { alarmListenerInstalled = false; } /** * Schedule one-shot sync retries after a failed inline snapshot. Uses * chrome.alarms (survives SW death) instead of setTimeout (does not). * Two retries: 30s and 2min. The 10-minute periodic alarm is the long-term * backstop. Chrome's minimum alarm resolution is ~30s in stable. */ export function schedulePostAdoptionSync(): void { if (!chrome.alarms?.create) return; const now = Date.now(); chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-1`, { delayInMinutes: 0.5, }); chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-2`, { delayInMinutes: 2, }); }