Auto-download desktop updates before prompting
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user