import { retryDelay, unreadableRecord } from "./managed-desktop.mjs"; const DAY = 24 * 60 * 60_000; const RETRY = 60 * 60_000; const RESUME_DELAY = 15 * 60_000; const validTime = value => Number.isSafeInteger(value) && value >= 0; const usableSchedule = saved => Boolean(saved) && [1, 2].includes(saved.version) && typeof saved.scope === "string" && saved.scope.length <= 8192 && validTime(saved.nextBackupAt) && (saved.lastAttemptAt === undefined || validTime(saved.lastAttemptAt)) && (saved.lastBackupAt === undefined || validTime(saved.lastBackupAt)); const sameScope = (left, right) => Boolean(left && right && left.key === right.key && left.generation === right.generation); const legacyScope = (record, authority) => Boolean(record && authority && record.scope !== authority.key && authority.adopts?.(record.scope)); export function createCompanyBackupSchedule({ store, scope, run, onState = () => {}, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) { let record = null, revision = 0, timer = null, closed = false, started = false; let controller = null, operation = null, status = "off", message, needsClear = false, awaitingAuthority = false; let restoreTimer = null, restoreFailures = 0, restorePending = false; const snapshot = () => ({ enabled: Boolean(record) || needsClear || restorePending, status, ...(record ? { nextBackupAt: record.nextBackupAt, ...(record.lastAttemptAt === undefined ? {} : { lastAttemptAt: record.lastAttemptAt }), ...(record.lastBackupAt === undefined ? {} : { lastBackupAt: record.lastBackupAt }), } : {}), ...(message ? { message } : {}) }); const publish = (nextStatus, nextMessage) => { status = nextStatus; message = nextMessage; onState(snapshot()); return snapshot(); }; const stopTimer = () => { if (timer !== null) clearTimer(timer); timer = null; }; const stopRestore = () => { if (restoreTimer !== null) clearTimer(restoreTimer); restoreTimer = null; }; const current = stamp => !closed && stamp === revision; const arm = () => { stopTimer(); if (!closed && started && record && !operation) { const authority = scope(); let delay = authority ? Math.max(1000, Math.min(DAY, record.nextBackupAt - now())) : RETRY; if (authority && awaitingAuthority) { awaitingAuthority = false; delay = Math.max(delay, RESUME_DELAY); } else if (!authority) awaitingAuthority = true; timer = setTimer(() => { timer = null; return tick().catch(() => {}); }, delay); timer?.unref?.(); } }; async function forget() { const stamp = ++revision; stopTimer(); stopRestore(); restorePending = false; controller?.abort(); record = null; needsClear = true; publish("paused", "Turning daily backups off."); try { await store.write(null); if (current(stamp)) { needsClear = false; publish("off"); } } catch { if (current(stamp)) publish("error", "The saved schedule could not be cleared. Unlock your system keychain and turn daily backups off again before restarting."); throw new Error("The saved daily backup schedule could not be cleared."); } return snapshot(); } async function adopt(authority) { const stamp = revision, adopted = { ...record, scope: authority.key }; await store.write(adopted); if (current(stamp) && record && legacyScope(record, authority)) record = adopted; } async function tick() { if (closed || !started || !record || operation) return; const currentScope = scope(), authority = currentScope ? { ...currentScope } : null; if (legacyScope(record, authority)) { try { await adopt(authority); } catch { publish("error", "Daily backups could not be updated. Unlock your system keychain; they will retry in an hour."); arm(); return; } } if (authority && authority.key !== record.scope) { await forget(); return; } if (!authority) { awaitingAuthority = true; publish("paused", "Daily backups will resume when this installation and the company connection are available."); arm(); return; } if (record.nextBackupAt > now() || awaitingAuthority) { publish("waiting"); arm(); return; } const stamp = revision, abort = new AbortController(); controller = abort; const active = record; // Reserve the retry before starting any work, including after a crash. active.nextBackupAt = now() + RETRY; active.lastAttemptAt = now(); const work = (async () => { try { await store.write({ ...active }); if (!current(stamp) || !sameScope(authority, scope())) return; abort.signal.throwIfAborted(); publish("running"); await run(abort.signal, authority); if (!current(stamp) || !sameScope(authority, scope())) return; abort.signal.throwIfAborted(); const completed = { ...active, lastBackupAt: now(), nextBackupAt: now() + DAY }; await store.write(completed); if (current(stamp)) { record = completed; publish("waiting"); } } catch (error) { if (!current(stamp)) return; publish(abort.signal.aborted || error?.code === "workspace_busy" ? "paused" : "error", abort.signal.aborted || error?.code === "workspace_busy" ? "Daily backup postponed. It will retry when this installation is available." : "The daily backup did not complete. Check your connection, system keychain and free disk space; it will retry in an hour."); } })(); operation = work; try { await work; } finally { if (operation === work) operation = null; if (controller === abort) controller = null; arm(); } } const RESTORE_WAITING = "The saved daily backup schedule can't be read right now. OpenMausBot tries again every minute."; /** A schedule that may only be locked in the keychain is kept, never removed, and read again shortly. */ async function restore() { const stamp = revision; let saved, failed = false, unreadable = false; try { saved = await store.read(); } catch (error) { failed = true; unreadable = unreadableRecord(error); } if (!current(stamp)) return snapshot(); if (!failed && saved !== null && !usableSchedule(saved)) failed = unreadable = true; if (unreadable) { const removed = await store.write(null).then(() => true, () => false); if (!current(stamp)) return snapshot(); if (removed) { restoreFailures = 0; restorePending = false; return publish("off", "The saved daily backup schedule couldn't be read, so it was turned off. Turn daily backups on again."); } } if (failed) { restoreFailures++; restorePending = true; if (status !== "paused" || message !== RESTORE_WAITING) publish("paused", RESTORE_WAITING); stopRestore(); restoreTimer = setTimer(() => { restoreTimer = null; if (current(stamp)) return restore().catch(() => {}); }, retryDelay(restoreFailures)); restoreTimer?.unref?.(); return snapshot(); } // Nothing uploads the moment a locked schedule opens. if (restorePending && saved !== null) awaitingAuthority = true; restoreFailures = 0; restorePending = false; try { if (saved !== null) { record = { version: 2, scope: saved.scope, nextBackupAt: Math.min(saved.nextBackupAt, now() + DAY), ...(saved.lastAttemptAt === undefined ? {} : { lastAttemptAt: saved.lastAttemptAt }), ...(saved.lastBackupAt === undefined ? {} : { lastBackupAt: saved.lastBackupAt }) }; if (saved.version === 1) await store.write({ ...record }); if (!current(stamp)) return snapshot(); } publish(record ? "waiting" : "off"); await tick(); } catch { if (current(stamp)) { record = null; publish("error", "Daily backups could not be restored. Unlock your system keychain and enable them again."); } } return snapshot(); } return { state: snapshot, async start() { if (started || closed) return snapshot(); started = true; return restore(); }, async configure(input) { if (closed) throw new Error("The desktop is shutting down."); if (input?.enabled === false && Object.keys(input).length === 1) return forget(); if (needsClear) throw new Error("Finish turning daily backups off before enabling them again."); if (input?.enabled !== true || input.confirmation !== "BACK UP THIS WORKSPACE DAILY" || Object.keys(input).some(key => !["enabled", "confirmation"].includes(key))) throw new Error("Confirm daily backup of this entire installation."); const currentScope = scope(), authority = currentScope ? { ...currentScope } : null; if (!authority) throw new Error("Connect your organization in the local desktop before enabling daily backups."); const stamp = ++revision; stopTimer(); stopRestore(); restorePending = false; controller?.abort(); record = { version: 2, scope: authority.key, nextBackupAt: now() + DAY }; try { await store.write({ ...record }); if (!current(stamp)) return snapshot(); if (!sameScope(authority, scope())) { await forget(); throw new Error("Your organization connection changed."); } started = true; publish("waiting"); arm(); return snapshot(); } catch (error) { if (current(stamp)) { record = null; publish("error", "Daily backups were not enabled. Unlock your system keychain and try again."); } throw error; } }, forget, reconcile() { const authority = scope(); if (!authority) { controller?.abort(); if (record) awaitingAuthority = true; } if (legacyScope(record, authority)) { void adopt(authority).then(() => arm(), () => arm()); return; } if (record && authority && record.scope !== authority.key) { void forget().catch(() => {}); return; } // Repeated connection refreshes cannot bring a persisted retry forward. arm(); }, close() { closed = true; revision++; stopTimer(); stopRestore(); restorePending = false; controller?.abort(); record = null; }, }; }