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 {...@@ -425,7 +425,9 @@ function dispatchMenuAction(action: string): void {
425425
426function handleCheckForUpdatesMenuClick(): void {426function handleCheckForUpdatesMenuClick(): void {
427 if (updateState.status === "downloaded") {427 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 );
429 return;431 return;
430 }432 }
431433
...@@ -576,7 +578,11 @@ function shouldEnableAutoUpdates(): boolean {...@@ -576,7 +578,11 @@ function shouldEnableAutoUpdates(): boolean {
576578
577async function checkForUpdates(reason: string): Promise<void> {579async function checkForUpdates(reason: string): Promise<void> {
578 if (!updaterConfigured || updateCheckInFlight) return;580 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 ) {
580 console.info(586 console.info(
581 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,587 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
582 );588 );
...@@ -639,8 +645,9 @@ function installDownloadedUpdate(): void {...@@ -639,8 +645,9 @@ function installDownloadedUpdate(): void {
639645
640 isQuitting = true;646 isQuitting = true;
641 clearUpdatePollTimer();647 clearUpdatePollTimer();
642 stopBackend();648 void stopBackendAndWaitForExit().finally(() => {
643 autoUpdater.quitAndInstall();649 autoUpdater.quitAndInstall();
650 });
644}651}
645652
646function configureAutoUpdater(): void {653function configureAutoUpdater(): void {
...@@ -674,6 +681,7 @@ function configureAutoUpdater(): void {...@@ -674,6 +681,7 @@ function configureAutoUpdater(): void {
674 autoUpdater.autoDownload = false;681 autoUpdater.autoDownload = false;
675 autoUpdater.autoInstallOnAppQuit = false;682 autoUpdater.autoInstallOnAppQuit = false;
676 autoUpdater.allowPrerelease = app.getVersion().includes("-");683 autoUpdater.allowPrerelease = app.getVersion().includes("-");
684 let lastLoggedDownloadMilestone = -1;
677685
678 autoUpdater.on("checking-for-update", () => {686 autoUpdater.on("checking-for-update", () => {
679 console.info("[desktop-updater] Looking for updates...");687 console.info("[desktop-updater] Looking for updates...");
...@@ -687,6 +695,7 @@ function configureAutoUpdater(): void {...@@ -687,6 +695,7 @@ function configureAutoUpdater(): void {
687 checkedAt: new Date().toISOString(),695 checkedAt: new Date().toISOString(),
688 message: null,696 message: null,
689 });697 });
698 lastLoggedDownloadMilestone = -1;
690 console.info(`[desktop-updater] Update available: ${info.version}`);699 console.info(`[desktop-updater] Update available: ${info.version}`);
691 });700 });
692 autoUpdater.on("update-not-available", () => {701 autoUpdater.on("update-not-available", () => {
...@@ -698,14 +707,19 @@ function configureAutoUpdater(): void {...@@ -698,14 +707,19 @@ function configureAutoUpdater(): void {
698 checkedAt: new Date().toISOString(),707 checkedAt: new Date().toISOString(),
699 message: null,708 message: null,
700 });709 });
710 lastLoggedDownloadMilestone = -1;
701 console.info("[desktop-updater] No updates available.");711 console.info("[desktop-updater] No updates available.");
702 });712 });
703 autoUpdater.on("error", (error) => {713 autoUpdater.on("error", (error) => {
704 const message = error instanceof Error ? error.message : String(error);714 const message = formatErrorMessage(error);
705 setUpdateState({715 if (!updateCheckInFlight && !updateDownloadInFlight) {
706 status: "error",716 setUpdateState({
707 message,717 status: "error",
708 });718 message,
719 checkedAt: new Date().toISOString(),
720 downloadPercent: null,
721 });
722 }
709 console.error(`[desktop-updater] Updater error: ${message}`);723 console.error(`[desktop-updater] Updater error: ${message}`);
710 });724 });
711 autoUpdater.on("download-progress", (progress) => {725 autoUpdater.on("download-progress", (progress) => {
...@@ -715,7 +729,9 @@ function configureAutoUpdater(): void {...@@ -715,7 +729,9 @@ function configureAutoUpdater(): void {
715 downloadPercent: progress.percent,729 downloadPercent: progress.percent,
716 message: null,730 message: null,
717 });731 });
718 if (percent % 10 === 0) {732 const milestone = percent - (percent % 10);
733 if (milestone > lastLoggedDownloadMilestone) {
734 lastLoggedDownloadMilestone = milestone;
719 console.info(`[desktop-updater] Download progress: ${percent}%`);735 console.info(`[desktop-updater] Download progress: ${percent}%`);
720 }736 }
721 });737 });
...@@ -842,6 +858,57 @@ function stopBackend(): void {...@@ -842,6 +858,57 @@ function stopBackend(): void {
842 }858 }
843}859}
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
845function registerIpcHandlers(): void {912function registerIpcHandlers(): void {
846 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);913 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
847 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {914 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
apps/web/src/components/Sidebar.tsx+6-2
...@@ -859,6 +859,9 @@ export default function Sidebar() {...@@ -859,6 +859,9 @@ export default function Sidebar() {
859 : "Update available";859 : "Update available";
860860
861 const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading";861 const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading";
862 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
863 ? "cursor-not-allowed opacity-60"
864 : "hover:bg-accent hover:text-foreground";
862 const desktopUpdateButtonClasses =865 const desktopUpdateButtonClasses =
863 desktopUpdateState?.status === "downloaded"866 desktopUpdateState?.status === "downloaded"
864 ? "text-emerald-500"867 ? "text-emerald-500"
...@@ -869,6 +872,7 @@ export default function Sidebar() {...@@ -869,6 +872,7 @@ export default function Sidebar() {
869 const handleDesktopUpdateButtonClick = useCallback(() => {872 const handleDesktopUpdateButtonClick = useCallback(() => {
870 const bridge = window.desktopBridge;873 const bridge = window.desktopBridge;
871 if (!bridge || !desktopUpdateState) return;874 if (!bridge || !desktopUpdateState) return;
875 if (desktopUpdateState.status === "downloading") return;
872876
873 if (desktopUpdateState.status === "available") {877 if (desktopUpdateState.status === "available") {
874 void bridge.downloadUpdate().catch((error) => {878 void bridge.downloadUpdate().catch((error) => {
...@@ -931,8 +935,8 @@ export default function Sidebar() {...@@ -931,8 +935,8 @@ export default function Sidebar() {
931 <button935 <button
932 type="button"936 type="button"
933 aria-label={desktopUpdateTooltip}937 aria-label={desktopUpdateTooltip}
934 disabled={desktopUpdateButtonDisabled}938 aria-disabled={desktopUpdateButtonDisabled || undefined}
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}`}939 className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
936 onClick={handleDesktopUpdateButtonClick}940 onClick={handleDesktopUpdateButtonClick}
937 >941 >
938 <RocketIcon className="size-3.5" />942 <RocketIcon className="size-3.5" />