Auto-download desktop updates before prompting

This commit is contained in:
Mohamed Boudra
2026-04-10 09:53:38 +07:00
parent 914a5dc6ae
commit 2683dae5f9
5 changed files with 159 additions and 51 deletions
@@ -4,6 +4,7 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export interface DesktopAppUpdateCheckResult { export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean; hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string | null; currentVersion: string | null;
latestVersion: string | null; latestVersion: string | null;
body: string | null; body: string | null;
@@ -76,6 +77,7 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
return { return {
hasUpdate: result.hasUpdate === true, hasUpdate: result.hasUpdate === true,
readyToInstall: result.readyToInstall === true,
currentVersion: toStringOrNull(result.currentVersion), currentVersion: toStringOrNull(result.currentVersion),
latestVersion: toStringOrNull(result.latestVersion), latestVersion: toStringOrNull(result.latestVersion),
body: toStringOrNull(result.body), body: toStringOrNull(result.body),
@@ -55,7 +55,7 @@ export function UpdateBanner() {
function getSubtitle(): string { function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version."; if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing..."; if (isInstalling) return "Installing and restarting...";
if (isError) return errorMessage ?? "Something went wrong."; if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`; return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
} }
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
checkDesktopAppUpdate, checkDesktopAppUpdate,
formatVersionWithPrefix, formatVersionWithPrefix,
@@ -11,12 +11,15 @@ import {
export type DesktopAppUpdateStatus = export type DesktopAppUpdateStatus =
| "idle" | "idle"
| "checking" | "checking"
| "pending"
| "up-to-date" | "up-to-date"
| "available" | "available"
| "installing" | "installing"
| "installed" | "installed"
| "error"; | "error";
const PENDING_RECHECK_MS = 10_000;
export interface UseDesktopAppUpdaterReturn { export interface UseDesktopAppUpdaterReturn {
isDesktopApp: boolean; isDesktopApp: boolean;
status: DesktopAppUpdateStatus; status: DesktopAppUpdateStatus;
@@ -56,11 +59,15 @@ function formatStatusText(input: {
return "App is up to date."; return "App is up to date.";
} }
if (status === "pending") {
return "We'll let you know when the update is ready.";
}
if (status === "available") { if (status === "available") {
if (availableUpdate?.latestVersion) { if (availableUpdate?.latestVersion) {
return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`; return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
} }
return "An app update is available."; return "An app update is ready to install.";
} }
if (status === "installed") { if (status === "installed") {
@@ -106,9 +113,12 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setInstallMessage(null); setInstallMessage(null);
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
if (result.hasUpdate) { if (result.readyToInstall) {
setAvailableUpdate(result); setAvailableUpdate(result);
setStatus("available"); setStatus("available");
} else if (result.hasUpdate) {
setAvailableUpdate(null);
setStatus("pending");
} else { } else {
setAvailableUpdate(null); setAvailableUpdate(null);
setStatus("up-to-date"); setStatus("up-to-date");
@@ -133,6 +143,20 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
[isDesktopApp], [isDesktopApp],
); );
useEffect(() => {
if (!isDesktopApp || status !== "pending") {
return undefined;
}
const intervalId = setInterval(() => {
void checkForUpdates({ silent: true });
}, PENDING_RECHECK_MS);
return () => {
clearInterval(intervalId);
};
}, [checkForUpdates, isDesktopApp, status]);
const installUpdate = useCallback(async () => { const installUpdate = useCallback(async () => {
if (!isDesktopApp) { if (!isDesktopApp) {
return null; return null;
+1 -1
View File
@@ -807,7 +807,7 @@ function DesktopAppUpdateRow() {
<Text style={styles.aboutHintText}>{statusText}</Text> <Text style={styles.aboutHintText}>{statusText}</Text>
{availableUpdate?.latestVersion ? ( {availableUpdate?.latestVersion ? (
<Text style={styles.aboutHintText}> <Text style={styles.aboutHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)} Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text> </Text>
) : null} ) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
+127 -45
View File
@@ -7,6 +7,7 @@ import { autoUpdater, type UpdateInfo } from "electron-updater";
export type AppUpdateCheckResult = { export type AppUpdateCheckResult = {
hasUpdate: boolean; hasUpdate: boolean;
readyToInstall: boolean;
currentVersion: string; currentVersion: string;
latestVersion: string; latestVersion: string;
body: string | null; body: string | null;
@@ -24,19 +25,84 @@ export type AppUpdateInstallResult = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let cachedUpdateInfo: UpdateInfo | null = null; let cachedUpdateInfo: UpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false; let downloading = false;
let autoUpdaterConfigured = false;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Configuration // Configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function configureAutoUpdater(): void { function configureAutoUpdater(): void {
// Don't auto-download — the user triggers install explicitly. // Download updates in the background and only prompt once they are ready to install.
autoUpdater.autoDownload = false; autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
// Suppress built-in dialogs; the renderer handles UI. // Suppress built-in dialogs; the renderer handles UI.
autoUpdater.autoRunAppAfterInstall = true; autoUpdater.autoRunAppAfterInstall = true;
if (autoUpdaterConfigured) {
return;
}
autoUpdaterConfigured = true;
autoUpdater.on("update-available", (info) => {
cachedUpdateInfo = info;
downloadedUpdateVersion = null;
downloading = true;
});
autoUpdater.on("update-downloaded", (info) => {
cachedUpdateInfo = info;
downloadedUpdateVersion = info.version;
downloading = false;
});
autoUpdater.on("update-not-available", () => {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
});
autoUpdater.on("error", (error) => {
downloading = false;
console.error("[auto-updater] Updater event failed:", error);
});
}
function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version;
}
function buildCheckResult(input: {
currentVersion: string;
hasUpdate: boolean;
readyToInstall: boolean;
info?: UpdateInfo | null;
}): AppUpdateCheckResult {
const { currentVersion, hasUpdate, readyToInstall, info } = input;
return {
hasUpdate,
readyToInstall,
currentVersion,
latestVersion: info?.version ?? currentVersion,
body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null,
date: typeof info?.releaseDate === "string" ? info.releaseDate : null,
};
}
function scheduleQuitAndInstall(onBeforeQuit?: () => Promise<void>): void {
// Use a short delay to allow the renderer to receive the response.
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,28 +111,34 @@ function configureAutoUpdater(): void {
export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> { export async function checkForAppUpdate(currentVersion: string): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) { if (!app.isPackaged) {
return { return buildCheckResult({
hasUpdate: false,
currentVersion, currentVersion,
latestVersion: currentVersion, hasUpdate: false,
body: null, readyToInstall: false,
date: null, });
};
} }
configureAutoUpdater(); configureAutoUpdater();
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (cachedVersion && cachedVersion !== currentVersion) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
try { try {
const result = await autoUpdater.checkForUpdates(); const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) { if (!result || !result.updateInfo) {
return { return buildCheckResult({
hasUpdate: false,
currentVersion, currentVersion,
latestVersion: currentVersion, hasUpdate: false,
body: null, readyToInstall: false,
date: null, });
};
} }
const info = result.updateInfo; const info = result.updateInfo;
@@ -75,24 +147,31 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
if (hasUpdate) { if (hasUpdate) {
cachedUpdateInfo = info; cachedUpdateInfo = info;
downloading = !isReadyToInstallVersion(latestVersion);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
});
} }
return { cachedUpdateInfo = null;
hasUpdate, downloadedUpdateVersion = null;
downloading = false;
return buildCheckResult({
currentVersion, currentVersion,
latestVersion, hasUpdate: false,
body: typeof info.releaseNotes === "string" ? info.releaseNotes : null, readyToInstall: false,
date: typeof info.releaseDate === "string" ? info.releaseDate : null, });
};
} catch (error) { } catch (error) {
console.error("[auto-updater] Failed to check for updates:", error); console.error("[auto-updater] Failed to check for updates:", error);
return { return buildCheckResult({
hasUpdate: false,
currentVersion, currentVersion,
latestVersion: currentVersion, hasUpdate: false,
body: null, readyToInstall: false,
date: null, });
};
} }
} }
@@ -108,14 +187,6 @@ export async function downloadAndInstallUpdate(
}; };
} }
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update already in progress.",
};
}
if (!cachedUpdateInfo) { if (!cachedUpdateInfo) {
return { return {
installed: false, installed: false,
@@ -126,24 +197,35 @@ export async function downloadAndInstallUpdate(
configureAutoUpdater(); configureAutoUpdater();
const readyVersion = cachedUpdateInfo.version;
if (isReadyToInstallVersion(readyVersion)) {
scheduleQuitAndInstall(onBeforeQuit);
return {
installed: true,
version: readyVersion,
message: "Update downloaded. The app will restart shortly.",
};
}
if (downloading) {
return {
installed: false,
version: currentVersion,
message: "Update is still being prepared. Try again in a moment.",
};
}
downloading = true; downloading = true;
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
// quitAndInstall restarts the app with the new version. downloadedUpdateVersion = readyVersion;
// Use a short delay to allow the renderer to receive the response. downloading = false;
setTimeout(async () => { scheduleQuitAndInstall(onBeforeQuit);
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);
}
}, 1500);
return { return {
installed: true, installed: true,
version: cachedUpdateInfo.version, version: readyVersion,
message: "Update downloaded. The app will restart shortly.", message: "Update downloaded. The app will restart shortly.",
}; };
} catch (error) { } catch (error) {