Ctrl K
ComunidadGuías…

electron/company-backup-schedule.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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; },
  };
}