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 };