feat(updater): add update status summary, logging, and new IPC channels

- Add UpdateStatusSummary type and getUpdateStatusSummary() for exposing
  updater state to renderer processes
- Add writeUpdaterLog() for persistent updater diagnostics
- Add configureUpdateFeed() for feed URL configuration
- Wire up get-update-status-summary and check-for-app-updates IPC handlers
- Add primaryAction field to UpdateToastState for actionable toast buttons
- Add checking-for-update event handler with status tracking
- Surface automatic check errors via toast when appropriate
This commit is contained in:
webadderall
2026-03-29 19:30:01 +11:00
parent ac14768a62
commit f5c92964a4
3 changed files with 198 additions and 1 deletions
+10
View File
@@ -50,6 +50,14 @@ interface UpdateToastState {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
}
interface UpdateStatusSummary {
status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error";
currentVersion: string;
availableVersion: string | null;
detail?: string;
}
interface Window {
@@ -303,7 +311,9 @@ interface Window {
dismissUpdateToast: () => Promise<{ success: boolean }>;
skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>;
getCurrentUpdateToastPayload: () => Promise<UpdateToastState | null>;
getUpdateStatusSummary: () => Promise<UpdateStatusSummary>;
previewUpdateToast: () => Promise<{ success: boolean }>;
checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>;
onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void;
onUpdateReadyToast: (callback: (payload: {
version: string;
+8
View File
@@ -265,9 +265,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
getCurrentUpdateToastPayload: () => {
return ipcRenderer.invoke("get-current-update-toast-payload");
},
getUpdateStatusSummary: () => {
return ipcRenderer.invoke("get-update-status-summary");
},
previewUpdateToast: () => {
return ipcRenderer.invoke("preview-update-toast");
},
checkForAppUpdates: () => {
return ipcRenderer.invoke("check-for-app-updates");
},
onUpdateToastStateChanged: (
callback: (payload: {
version: string;
@@ -276,6 +282,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
} | null) => void,
) => {
const listener = (
@@ -288,6 +295,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
}
| null,
) => callback(payload);
+180 -1
View File
@@ -1,17 +1,41 @@
import fs from "node:fs";
import path from "node:path";
import { app, BrowserWindow, dialog } from "electron";
import { autoUpdater } from "electron-updater";
import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
import { USER_DATA_PATH } from "./appPaths";
const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
const DISMISSED_READY_REMINDER_DELAY_MS = 5 * 60 * 1000;
const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1";
const AUTO_UPDATE_ERROR_TOASTS_DISABLED =
process.env.RECORDLY_DISABLE_AUTO_UPDATE_ERROR_TOASTS === "1";
const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ?? "";
const UPDATER_LOG_PATH =
process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log");
const DEV_UPDATE_PREVIEW_VERSION = "9.9.9";
const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300;
const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20;
export type UpdateToastPhase = "available" | "downloading" | "ready" | "error";
export type UpdateStatusKind =
| "idle"
| "checking"
| "up-to-date"
| "available"
| "downloading"
| "ready"
| "error";
export interface UpdateStatusSummary {
status: UpdateStatusKind;
currentVersion: string;
availableVersion: string | null;
detail?: string;
}
export interface UpdateToastPayload {
version: string;
detail: string;
@@ -19,6 +43,7 @@ export interface UpdateToastPayload {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
}
type UpdateToastSender = (
@@ -38,6 +63,72 @@ let pendingDownloadedVersion: string | null = null;
let downloadInProgress = false;
let downloadToastDismissed = false;
let skippedVersion: string | null = null;
let updateCheckErrorHandled = false;
let activeUpdateToastSender: UpdateToastSender | undefined;
let updateStatusSummary: UpdateStatusSummary = {
status: "idle",
currentVersion: app.getVersion(),
availableVersion: null,
};
function setUpdateStatusSummary(summary: Partial<UpdateStatusSummary>) {
updateStatusSummary = {
...updateStatusSummary,
currentVersion: app.getVersion(),
...summary,
};
}
function summarizeError(error: unknown) {
if (error instanceof Error) {
return error.stack || `${error.name}: ${error.message}`;
}
return String(error);
}
function writeUpdaterLog(message: string, detail?: unknown) {
try {
fs.mkdirSync(path.dirname(UPDATER_LOG_PATH), { recursive: true });
const suffix = detail === undefined ? "" : ` ${summarizeError(detail)}`;
fs.appendFileSync(
UPDATER_LOG_PATH,
`${new Date().toISOString()} ${message}${suffix}\n`,
"utf8",
);
} catch (logError) {
console.error("Failed to write updater log:", logError);
}
}
function createAutoCheckErrorToastPayload(): UpdateToastPayload {
return {
version: app.getVersion(),
phase: "error",
detail:
"Recordly could not check for updates automatically. Retry now, or inspect updater.log in your user data folder.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "retry-check",
};
}
function shouldSurfaceAutomaticCheckErrors() {
return !AUTO_UPDATE_ERROR_TOASTS_DISABLED;
}
function configureUpdateFeed() {
if (!UPDATE_FEED_URL_OVERRIDE) {
writeUpdaterLog("Using published GitHub update feed.");
return;
}
autoUpdater.setFeedURL({
provider: "generic",
url: UPDATE_FEED_URL_OVERRIDE,
channel: "latest",
});
writeUpdaterLog(`Using overridden update feed: ${UPDATE_FEED_URL_OVERRIDE}`);
}
function canUseAutoUpdates() {
return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas;
@@ -92,6 +183,7 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload
phase: "available",
detail: "A new version is available. Download it now, or wait and we will remind you again in 3 hours.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "download-update",
};
}
@@ -118,6 +210,7 @@ function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload
phase: "ready",
detail: "Install now to restart into the new version, or wait and we will remind you again in 3 hours.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "install-update",
};
}
@@ -127,6 +220,7 @@ function createUpdateErrorToastPayload(version: string, error: unknown): UpdateT
phase: "error",
detail: `The update download failed. ${String(error)}`,
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "download-update",
};
}
@@ -150,6 +244,14 @@ export function getCurrentUpdateToastPayload() {
return currentToastPayload;
}
export function getUpdaterLogPath() {
return UPDATER_LOG_PATH;
}
export function getUpdateStatusSummary() {
return updateStatusSummary;
}
async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) {
await showMessageBox(getMainWindow, {
type: "info",
@@ -267,6 +369,8 @@ export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) {
clearDeferredReminderTimer();
downloadToastDismissed = false;
clearVisibleUpdateToast(sendToRenderer);
setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion });
writeUpdaterLog("Installing downloaded update.");
autoUpdater.quitAndInstall();
}
@@ -290,13 +394,26 @@ export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender
clearDeferredReminderTimer();
downloadInProgress = true;
downloadToastDismissed = false;
setUpdateStatusSummary({
status: "downloading",
availableVersion,
detail: `Downloading Recordly ${availableVersion}`,
});
emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0));
writeUpdaterLog(`Starting update download for ${availableVersion}.`);
try {
await autoUpdater.downloadUpdate();
writeUpdaterLog(`Update download requested for ${availableVersion}.`);
return { success: true };
} catch (error) {
downloadInProgress = false;
setUpdateStatusSummary({
status: "error",
availableVersion,
detail: String(error),
});
writeUpdaterLog(`Update download failed for ${availableVersion}.`, error);
emitUpdateToastState(
sendToRenderer,
createUpdateErrorToastPayload(availableVersion, error),
@@ -464,6 +581,9 @@ export async function checkForAppUpdates(
options?: { manual?: boolean },
) {
if (!canUseAutoUpdates()) {
writeUpdaterLog(
`Skipped update check because auto-updates are unavailable. packaged=${app.isPackaged} mas=${process.mas ? "yes" : "no"} disabled=${AUTO_UPDATES_DISABLED ? "yes" : "no"}`,
);
if (options?.manual) {
await showMessageBox(getMainWindow, {
type: "info",
@@ -478,6 +598,7 @@ export async function checkForAppUpdates(
}
if (updateCheckInProgress) {
writeUpdaterLog("Skipped update check because a previous check is still running.");
if (options?.manual) {
await showMessageBox(getMainWindow, {
type: "info",
@@ -490,16 +611,28 @@ export async function checkForAppUpdates(
manualCheckRequested = Boolean(options?.manual);
updateCheckInProgress = true;
updateCheckErrorHandled = false;
setUpdateStatusSummary({ status: "checking", detail: "Checking for updates..." });
writeUpdaterLog(`Starting ${manualCheckRequested ? "manual" : "automatic"} update check.`);
try {
await autoUpdater.checkForUpdates();
writeUpdaterLog("Update check request completed.");
} catch (error) {
updateCheckInProgress = false;
const shouldReport = manualCheckRequested;
manualCheckRequested = false;
setUpdateStatusSummary({
status: "error",
availableVersion,
detail: String(error),
});
writeUpdaterLog("Update check failed.", error);
console.error("Auto-update check failed:", error);
if (shouldReport) {
if (shouldReport && !updateCheckErrorHandled) {
await showUpdateErrorDialog(getMainWindow, error);
} else if (!updateCheckErrorHandled && shouldSurfaceAutomaticCheckErrors()) {
emitUpdateToastState(activeUpdateToastSender, createAutoCheckErrorToastPayload());
}
}
}
@@ -513,19 +646,34 @@ export function setupAutoUpdates(
}
if (!canUseAutoUpdates()) {
setUpdateStatusSummary({ status: "idle", availableVersion: null, detail: undefined });
return;
}
updaterInitialized = true;
activeUpdateToastSender = sendToRenderer;
configureUpdateFeed();
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
writeUpdaterLog(`Updater initialized. logPath=${UPDATER_LOG_PATH}`);
autoUpdater.on("checking-for-update", () => {
setUpdateStatusSummary({ status: "checking", availableVersion: null, detail: "Checking for updates..." });
writeUpdaterLog("electron-updater emitted checking-for-update.");
});
autoUpdater.on("update-available", (info) => {
writeUpdaterLog(`Update available: version=${info.version}`);
updateCheckInProgress = false;
availableVersion = info.version;
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
setUpdateStatusSummary({
status: "available",
availableVersion: info.version,
detail: `Recordly ${info.version} is available.`,
});
if (skippedVersion === info.version) {
manualCheckRequested = false;
return;
@@ -544,11 +692,17 @@ export function setupAutoUpdates(
});
autoUpdater.on("update-not-available", () => {
writeUpdaterLog("No update available.");
updateCheckInProgress = false;
availableVersion = null;
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
setUpdateStatusSummary({
status: "up-to-date",
availableVersion: null,
detail: `Recordly ${app.getVersion()} is up to date.`,
});
clearVisibleUpdateToast(sendToRenderer);
const shouldReport = manualCheckRequested;
manualCheckRequested = false;
@@ -563,6 +717,14 @@ export function setupAutoUpdates(
}
downloadInProgress = true;
setUpdateStatusSummary({
status: "downloading",
availableVersion,
detail: `Downloading Recordly ${availableVersion}`,
});
writeUpdaterLog(
`Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`,
);
if (downloadToastDismissed) {
return;
}
@@ -577,6 +739,15 @@ export function setupAutoUpdates(
updateCheckInProgress = false;
const shouldReport = manualCheckRequested;
manualCheckRequested = false;
if (!downloadInProgress) {
updateCheckErrorHandled = true;
}
setUpdateStatusSummary({
status: "error",
availableVersion,
detail: String(error),
});
writeUpdaterLog("electron-updater emitted error.", error);
console.error("Auto-updater error:", error);
if (downloadInProgress && availableVersion) {
downloadInProgress = false;
@@ -588,10 +759,13 @@ export function setupAutoUpdates(
}
if (shouldReport) {
void showUpdateErrorDialog(getMainWindow, error);
} else if (shouldSurfaceAutomaticCheckErrors()) {
emitUpdateToastState(sendToRenderer, createAutoCheckErrorToastPayload());
}
});
autoUpdater.on("update-downloaded", (info) => {
writeUpdaterLog(`Update downloaded: version=${info.version}`);
updateCheckInProgress = false;
manualCheckRequested = false;
downloadInProgress = false;
@@ -601,6 +775,11 @@ export function setupAutoUpdates(
}
availableVersion = info.version;
pendingDownloadedVersion = info.version;
setUpdateStatusSummary({
status: "ready",
availableVersion: info.version,
detail: `Recordly ${info.version} is ready to install.`,
});
clearDeferredReminderTimer();
if (emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))) {