diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f9a9ed19..83fbe502 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; + getUpdateStatusSummary: () => Promise; previewUpdateToast: () => Promise<{ success: boolean }>; + checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>; onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void; onUpdateReadyToast: (callback: (payload: { version: string; diff --git a/electron/preload.ts b/electron/preload.ts index 2f76a239..a052fb95 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); diff --git a/electron/updater.ts b/electron/updater.ts index 0f939f89..bfb932dd 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -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, + 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))) {