updater.jsjavascript
/**
 * @file electron-updater wiring: auto-update against updates.ezlaybacks.com
 * (the auth-gated Worker in front of the private R2 bucket — see
 * updates-worker/ and docs/Auto_Update_Design.md). electron-updater reads the
 * feed URL from the app-update.yml that electron-builder embeds from
 * build.publish; this module supplies the entitlement header the Worker
 * demands, the check cadence, and the ONE piece of update UI the app has: a
 * single Restart Now / Later dialog when a download is ready. No OS
 * notifications, ever — the stock checkForUpdatesAndNotify re-notified on
 * every launch until the update was installed. The download is verified two
 * ways that we don't implement: sha512 from latest-mac.yml, and macOS
 * code-signature match against the running app.
 * @module updater
 */

const { app, dialog } = require('electron');
const { autoUpdater } = require('electron-updater');
const log = require('electron-log');
const settings = require('./settings');

/** Interval between background update checks. */
const CHECK_EVERY_MS = 4 * 60 * 60 * 1000;

/**
 * Decide whether the restart dialog should appear for a downloaded update —
 * one background prompt per version: update-downloaded fires again on EVERY
 * launch until the update installs (electron-updater re-finds the cached
 * download), so without this memory the dialog would re-nag daily. A manual
 * menu check always prompts — the user just asked.
 * @param {string|undefined} promptedVersion Version last prompted for (from settings).
 * @param {string} version Version just downloaded.
 * @param {boolean} manual True when the check came from the menu.
 * @returns {boolean} True when the dialog should be shown.
 */
function shouldPrompt(promptedVersion, version, manual) {
  return manual || promptedVersion !== version;
}

/** auth.js's cached entitlement accessor: () => base64 string, or null when signed out. */
let getAuthHeader = null;
/** Main window accessor — drives the dock-icon progress bar. */
let getWindow = null;
/** Updater state: 'idle' | 'checking' | 'downloading' | 'ready'. */
let state = 'idle';
/** Version string once update-downloaded has fired. */
let readyVersion = null;
/** Current check came from the menu — give honest feedback. */
let manualCheck = false;
/** Restart dialog on screen — don't stack a second one. */
let prompting = false;
/** 0-100 while state === 'downloading'. */
let downloadPercent = 0;

/**
 * Paint download progress on the dock icon. The download is ~160MB and used
 * to be invisible — it read as a hang. setProgressBar paints the standard
 * progress bar across the dock icon. No window of our own, no notification.
 * @param {number} fraction 0–1 progress; -1 removes the bar.
 */
function showProgress(fraction) {
  const win = getWindow?.();
  if (win && !win.isDestroyed()) win.setProgressBar(fraction);
}

/**
 * Show a one-button informational dialog.
 * @param {string} message Headline text.
 * @param {string} detail Body text.
 */
function infoBox(message, detail) {
  dialog.showMessageBox({ type: 'info', message, detail, buttons: ['OK'] });
}

/**
 * Show the Restart Now / Later dialog for a downloaded update; a dialog
 * already on screen suppresses a second one. quitAndInstall is deferred with
 * setImmediate: calling it from inside the dialog callback can stall the
 * quit sequence while the modal tears down — the app then never exits,
 * ShipIt times out on "App Still Running", and the click does nothing (the
 * other half of the 2026.715.2 restart failure).
 * @param {string} version Version ready to install.
 * @returns {Promise<void>}
 */
async function promptRestart(version) {
  if (prompting) return;
  prompting = true;
  try {
    const { response } = await dialog.showMessageBox({
      type: 'info',
      message: `EZLaybacks ${version} is ready to install`,
      detail: 'Restart now to finish updating, or keep working — the update installs when you quit.',
      buttons: ['Restart Now', 'Later'],
      defaultId: 0,
      cancelId: 1,
    });
    if (response === 0) setImmediate(() => autoUpdater.quitAndInstall());
  } finally {
    prompting = false;
  }
}

/**
 * Start one update check. Signed-out installs skip it (the Worker would 401
 * them); a manual check while signed out explains itself instead. The
 * entitlement header rides on the request as a Bearer token. Check failures
 * surface through the 'error' event; nothing to handle here.
 */
function runCheck() {
  const header = getAuthHeader();
  if (!header) {
    if (manualCheck) {
      manualCheck = false;
      infoBox('Sign in to check for updates', 'Updates are delivered to signed-in copies of EZLaybacks.');
    }
    return;
  }
  autoUpdater.requestHeaders = { Authorization: `Bearer ${header}` };
  autoUpdater.checkForUpdates().catch(() => {});
}

/**
 * Menu-initiated check ("Check for Updates…"). Unlike the background cadence,
 * every outcome gets said out loud: up to date, downloading, ready, or
 * failed. Dev runs aren't installed, so there's nothing to update and the
 * dialog says so. Setting manualCheck while a background check is in flight
 * makes that check report its outcome.
 */
function checkNow() {
  if (!app.isPackaged) {
    infoBox('Updates apply to the installed app', 'Dev runs are not installed, so there is nothing to update.');
    return;
  }
  if (state === 'ready') {
    promptRestart(readyVersion);
    return;
  }
  if (state === 'downloading') {
    infoBox('An update is on its way', `Downloading — ${downloadPercent}% done. You'll be asked to restart when it's ready.`);
    return;
  }
  manualCheck = true;
  if (state === 'checking') return;
  runCheck();
}

/**
 * Wire up auto-update and start the check cadence. No-op in dev runs — they
 * aren't installed, so there's nothing to update.
 *
 * Logging goes to a file at ~/Library/Logs/<app>/main.log — debugging the
 * first live run meant reading ShipIt's logs by hand; never again.
 * autoInstallOnAppQuit means "Later" = install on next quit.
 *
 * Event handling: update-available flips to downloading and zeroes the dock
 * progress bar; download-progress keeps it moving; update-not-available
 * returns to idle. update-downloaded records the ready version, clears the
 * progress bar, and prompts per shouldPrompt — when the prompt for this
 * version already happened, it stays quiet and the update installs on quit.
 * Errors (offline, 401, feed missing) are non-events for background checks
 * (next tick retries) and one calm sentence for a manual one; details go to
 * the log only, and a dead download must not leave a frozen progress bar, so
 * it's cleared. Manual checks additionally announce update-available and
 * update-not-available outcomes.
 *
 * The Settings toggle (app.checkUpdatesOnLaunch) gates the BACKGROUND
 * cadence only; checkNow() from the menu ignores it, because an explicit ask
 * is an explicit ask. Read once at start: a running app has already made its
 * decision, so a change takes effect on the next launch — which is what the
 * toggle's subtitle promises.
 * @param {function(): string|null} getHeader Returns the base64 entitlement header,
 * or null when signed out.
 * @param {function(): BrowserWindow|undefined} getWin Window accessor for the dock
 * progress bar.
 */
function start(getHeader, getWin) {
  getAuthHeader = getHeader;
  getWindow = getWin;
  if (!app.isPackaged) return;

  log.transports.file.level = 'info';
  autoUpdater.logger = log;

  autoUpdater.autoDownload = true;
  autoUpdater.autoInstallOnAppQuit = true;

  autoUpdater.on('checking-for-update', () => { state = 'checking'; });

  autoUpdater.on('update-available', (info) => {
    state = 'downloading';
    downloadPercent = 0;
    showProgress(0);
    if (manualCheck) {
      manualCheck = false;
      infoBox(`Downloading EZLaybacks ${info.version}`, "Progress shows on the dock icon — you'll be asked to restart when it's ready.");
    }
  });

  autoUpdater.on('download-progress', (p) => {
    downloadPercent = Math.round(p.percent);
    showProgress(p.percent / 100);
  });

  autoUpdater.on('update-not-available', () => {
    state = 'idle';
    if (manualCheck) {
      manualCheck = false;
      infoBox("You're up to date", `EZLaybacks ${app.getVersion()} is the latest version.`);
    }
  });

  autoUpdater.on('update-downloaded', (info) => {
    state = 'ready';
    showProgress(-1);
    readyVersion = info.version;
    const wasManual = manualCheck;
    manualCheck = false;
    if (shouldPrompt(settings.load().app.promptedUpdateVersion, info.version, wasManual)) {
      settings.update({ app: { promptedUpdateVersion: info.version } });
      promptRestart(info.version);
    }
  });

  autoUpdater.on('error', (err) => {
    state = 'idle';
    showProgress(-1);
    log.error('[updater]', err?.message ?? err);
    if (manualCheck) {
      manualCheck = false;
      infoBox("Couldn't check for updates", 'Please try again in a little while.');
    }
  });

  if (!settings.load().app.checkUpdatesOnLaunch) {
    log.info('[updater] background checks disabled in settings');
    return;
  }

  runCheck();
  setInterval(runCheck, CHECK_EVERY_MS);
}

module.exports = { start, checkNow, shouldPrompt };