authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 20:26:28-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 22:31:45-08:00
log392d63231cce39b2dcd56435ef24579a8b4f06c6
tree73d0aa3d9d81c9fcdcebf573e67a9b4e9d6ab9fb
parent7559f5ad70349b62d919e4a488ff70cc8c7847c6

Harden desktop update flow and prevent duplicate update actions

- Skip redundant update checks while update is available/downloading/downloaded - Wait for backend shutdown before `quitAndInstall` and improve updater error/progress handling - Prevent sidebar update button clicks during download while preserving tooltip interactivity

2 files changed, 83 insertions(+), 12 deletions(-)

apps/desktop/src/main.ts+77-10
......@@ -425,7 +425,9 @@ function dispatchMenuAction(action: string): void {
425425
426426function handleCheckForUpdatesMenuClick(): void {
427427 if (updateState.status === "downloaded") {
428 installDownloadedUpdate();
428 console.info(
429 "[desktop-updater] Check for updates clicked while an update is already downloaded; skipping automatic install.",
430 );
429431 return;
430432 }
431433
......@@ -576,7 +578,11 @@ function shouldEnableAutoUpdates(): boolean {
576578
577579async function checkForUpdates(reason: string): Promise<void> {
578580 if (!updaterConfigured || updateCheckInFlight) return;
579 if (updateState.status === "downloading" || updateState.status === "downloaded") {
581 if (
582 updateState.status === "downloading" ||
583 updateState.status === "downloaded" ||
584 updateState.status === "available"
585 ) {
580586 console.info(
581587 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
582588 );
......@@ -639,8 +645,9 @@ function installDownloadedUpdate(): void {
639645
640646 isQuitting = true;
641647 clearUpdatePollTimer();
642 stopBackend();
643 autoUpdater.quitAndInstall();
648 void stopBackendAndWaitForExit().finally(() => {
649 autoUpdater.quitAndInstall();
650 });
644651}
645652
646653function configureAutoUpdater(): void {
......@@ -674,6 +681,7 @@ function configureAutoUpdater(): void {
674681 autoUpdater.autoDownload = false;
675682 autoUpdater.autoInstallOnAppQuit = false;
676683 autoUpdater.allowPrerelease = app.getVersion().includes("-");
684 let lastLoggedDownloadMilestone = -1;
677685
678686 autoUpdater.on("checking-for-update", () => {
679687 console.info("[desktop-updater] Looking for updates...");
......@@ -687,6 +695,7 @@ function configureAutoUpdater(): void {
687695 checkedAt: new Date().toISOString(),
688696 message: null,
689697 });
698 lastLoggedDownloadMilestone = -1;
690699 console.info(`[desktop-updater] Update available: ${info.version}`);
691700 });
692701 autoUpdater.on("update-not-available", () => {
......@@ -698,14 +707,19 @@ function configureAutoUpdater(): void {
698707 checkedAt: new Date().toISOString(),
699708 message: null,
700709 });
710 lastLoggedDownloadMilestone = -1;
701711 console.info("[desktop-updater] No updates available.");
702712 });
703713 autoUpdater.on("error", (error) => {
704 const message = error instanceof Error ? error.message : String(error);
705 setUpdateState({
706 status: "error",
707 message,
708 });
714 const message = formatErrorMessage(error);
715 if (!updateCheckInFlight && !updateDownloadInFlight) {
716 setUpdateState({
717 status: "error",
718 message,
719 checkedAt: new Date().toISOString(),
720 downloadPercent: null,
721 });
722 }
709723 console.error(`[desktop-updater] Updater error: ${message}`);
710724 });
711725 autoUpdater.on("download-progress", (progress) => {
......@@ -715,7 +729,9 @@ function configureAutoUpdater(): void {
715729 downloadPercent: progress.percent,
716730 message: null,
717731 });
718 if (percent % 10 === 0) {
732 const milestone = percent - (percent % 10);
733 if (milestone > lastLoggedDownloadMilestone) {
734 lastLoggedDownloadMilestone = milestone;
719735 console.info(`[desktop-updater] Download progress: ${percent}%`);
720736 }
721737 });
......@@ -842,6 +858,57 @@ function stopBackend(): void {
842858 }
843859}
844860
861async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
862 if (restartTimer) {
863 clearTimeout(restartTimer);
864 restartTimer = null;
865 }
866
867 const child = backendProcess;
868 backendProcess = null;
869 if (!child) return;
870 const backendChild = child;
871 if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;
872
873 await new Promise<void>((resolve) => {
874 let settled = false;
875 let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
876 let exitTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
877
878 function settle(): void {
879 if (settled) return;
880 settled = true;
881 backendChild.off("exit", onExit);
882 if (forceKillTimer) {
883 clearTimeout(forceKillTimer);
884 }
885 if (exitTimeoutTimer) {
886 clearTimeout(exitTimeoutTimer);
887 }
888 resolve();
889 }
890
891 function onExit(): void {
892 settle();
893 }
894
895 backendChild.once("exit", onExit);
896 backendChild.kill("SIGTERM");
897
898 forceKillTimer = setTimeout(() => {
899 if (backendChild.exitCode === null && backendChild.signalCode === null) {
900 backendChild.kill("SIGKILL");
901 }
902 }, 2_000);
903 forceKillTimer.unref();
904
905 exitTimeoutTimer = setTimeout(() => {
906 settle();
907 }, timeoutMs);
908 exitTimeoutTimer.unref();
909 });
910}
911
845912function registerIpcHandlers(): void {
846913 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
847914 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
apps/web/src/components/Sidebar.tsx+6-2
......@@ -859,6 +859,9 @@ export default function Sidebar() {
859859 : "Update available";
860860
861861 const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading";
862 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
863 ? "cursor-not-allowed opacity-60"
864 : "hover:bg-accent hover:text-foreground";
862865 const desktopUpdateButtonClasses =
863866 desktopUpdateState?.status === "downloaded"
864867 ? "text-emerald-500"
......@@ -869,6 +872,7 @@ export default function Sidebar() {
869872 const handleDesktopUpdateButtonClick = useCallback(() => {
870873 const bridge = window.desktopBridge;
871874 if (!bridge || !desktopUpdateState) return;
875 if (desktopUpdateState.status === "downloading") return;
872876
873877 if (desktopUpdateState.status === "available") {
874878 void bridge.downloadUpdate().catch((error) => {
......@@ -931,8 +935,8 @@ export default function Sidebar() {
931935 <button
932936 type="button"
933937 aria-label={desktopUpdateTooltip}
934 disabled={desktopUpdateButtonDisabled}
935 className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 ${desktopUpdateButtonClasses}`}
938 aria-disabled={desktopUpdateButtonDisabled || undefined}
939 className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
936940 onClick={handleDesktopUpdateButtonClick}
937941 >
938942 <RocketIcon className="size-3.5" />