From 0d1d6e18a466c72c81274aa1304ad51c944b8cfb Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 20 Jul 2026 16:12:20 +0800 Subject: [PATCH] fix(updater): guard installs during active tasks --- apps/desktop/src/App.vue | 30 ++- .../components/layout/UpdateDialog.spec.ts | 222 ++++++++++++++++++ .../src/components/layout/UpdateDialog.vue | 30 ++- apps/desktop/src/composables/useAppUpdater.ts | 74 +++++- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../src/lib/app/appUpdateInstallFlow.ts | 31 +++ .../desktop/src/lib/app/appUpdateTaskGuard.ts | 17 ++ apps/desktop/src/lib/backend/api.ts | 3 +- apps/desktop/src/lib/backend/http.ts | 6 +- apps/desktop/src/lib/backend/tauri.ts | 8 +- packages/app-tests/appUpdater.test.ts | 56 +++++ src-tauri/src/commands/update.rs | 78 +++++- src-tauri/src/lib.rs | 4 +- 19 files changed, 554 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/components/layout/UpdateDialog.spec.ts create mode 100644 apps/desktop/src/lib/app/appUpdateInstallFlow.ts create mode 100644 apps/desktop/src/lib/app/appUpdateTaskGuard.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 745ed0cbd5..e3ceec281d 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -19,6 +19,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useAppUpdater } from "@/composables/useAppUpdater"; +import { useExportTracker } from "@/composables/useExportTracker"; import { useFileDrop } from "@/composables/useFileDrop"; import { usePanelResize } from "@/composables/usePanelResize"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; @@ -84,6 +85,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor import { apiUrl, webPath } from "@/lib/common/webPath"; import { APP_FONT_SANS_CSS_VAR, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory"; +import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard"; import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition"; import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -123,7 +125,29 @@ const savedSqlStore = useSavedSqlStore(); connectionStore.setBeforeConnectHandler((config) => ensureJdbcxRuntimeDrivers(config, api).then(() => undefined)); const { message: toastMessage, visible: toastVisible, toast } = useToast(); const { isDark, themeMode, applyTheme, setThemeMode } = useTheme(); -const { checkingUpdates, updateInfo, updateCheckMessage, showUpdateDialog, isDownloadingUpdate, downloadProgress, updateReady, hasUpdateAvailable, openUrl, checkUpdates, openLatestRelease, downloadAndInstallUpdate, restartApp } = useAppUpdater(); +const { activeCount: activeBackgroundTaskCount } = useExportTracker(); +const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs)); +const { + checkingUpdates, + updateInfo, + updateCheckMessage, + showUpdateDialog, + isDownloadingUpdate, + downloadProgress, + updateDownloaded, + isInstallingUpdate, + updateReady, + activeTaskCount: activeUpdateTaskCount, + hasUpdateAvailable, + openUrl, + checkUpdates, + openLatestRelease, + downloadAndInstallUpdate, + installDownloadedUpdate, + restartApp, +} = useAppUpdater({ + getActiveTaskCount: () => trackedUpdateTaskCount.value, +}); const { setupFileDrop } = useFileDrop(); const isDesktop = isTauriRuntime(); @@ -2270,9 +2294,13 @@ onUnmounted(() => { :update-check-message="updateCheckMessage" :is-downloading-update="isDownloadingUpdate" :download-progress="downloadProgress" + :update-downloaded="updateDownloaded" + :is-installing-update="isInstallingUpdate" :update-ready="updateReady" + :active-task-count="activeUpdateTaskCount" @open-latest-release="openLatestRelease" @download-and-install="downloadAndInstallUpdate" + @install-downloaded="installDownloadedUpdate" @restart="restartApp" /> diff --git a/apps/desktop/src/components/layout/UpdateDialog.spec.ts b/apps/desktop/src/components/layout/UpdateDialog.spec.ts new file mode 100644 index 0000000000..298c86d11e --- /dev/null +++ b/apps/desktop/src/components/layout/UpdateDialog.spec.ts @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import UpdateDialog from "@/components/layout/UpdateDialog.vue"; + +vi.mock("@/lib/backend/tauriRuntime", () => ({ + isTauriRuntime: () => true, +})); + +const mountedApps: App[] = []; + +interface DialogState { + open: boolean; + isDownloadingUpdate: boolean; + downloadProgress: number; + updateDownloaded: boolean; + isInstallingUpdate: boolean; + updateReady: boolean; +} + +async function flushDialog() { + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function mountDialog(activeTaskCount: number, initialState: Partial = {}, installDownloaded = vi.fn(async () => {})) { + const state = reactive({ + open: true, + isDownloadingUpdate: false, + downloadProgress: 0, + updateDownloaded: false, + isInstallingUpdate: false, + updateReady: false, + ...initialState, + }); + const downloadAndInstall = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup() { + async function handleInstallDownloaded() { + state.isInstallingUpdate = true; + try { + await installDownloaded(); + state.updateDownloaded = false; + state.updateReady = true; + } catch { + // The real updater reports the error but retains the downloaded package for retry. + } finally { + state.isInstallingUpdate = false; + } + } + + return () => + h(UpdateDialog, { + open: state.open, + "onUpdate:open": (value: boolean) => { + state.open = value; + }, + updateInfo: { + current_version: "0.5.60", + latest_version: "0.5.61", + update_available: true, + portable_mode: false, + release_name: "DBX v0.5.61", + release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.61", + release_notes: "", + }, + updateCheckMessage: "", + isDownloadingUpdate: state.isDownloadingUpdate, + downloadProgress: state.downloadProgress, + updateDownloaded: state.updateDownloaded, + isInstallingUpdate: state.isInstallingUpdate, + updateReady: state.updateReady, + activeTaskCount, + "onDownload-and-install": downloadAndInstall, + "onInstall-downloaded": handleInstallDownloaded, + }); + }, + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + await flushDialog(); + + return { state, downloadAndInstall, installDownloaded }; +} + +function buttonWithText(text: string): HTMLButtonElement | undefined { + return Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes(text)); +} + +function downloadButton(): HTMLButtonElement | undefined { + return buttonWithText("Download & Install"); +} + +function installDownloadedButton(): HTMLButtonElement | undefined { + return buttonWithText("Exit & Update"); +} + +async function pressEscape() { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + await flushDialog(); +} + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("UpdateDialog active task guard", () => { + it("shows the task warning and disables installation while work is running", async () => { + await mountDialog(2); + + expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("2"); + expect(downloadButton()?.disabled).toBe(true); + }); + + it("allows installation after all tasks finish", async () => { + await mountDialog(0); + + expect(document.body.querySelector('[role="alert"]')).toBeNull(); + expect(downloadButton()?.disabled).toBe(false); + }); + + it("retains the downloaded update and enables installation only after tasks finish", async () => { + await mountDialog(1, { updateDownloaded: true, downloadProgress: 100 }); + + expect(downloadButton()).toBeUndefined(); + expect(installDownloadedButton()?.disabled).toBe(true); + + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 }); + + expect(installDownloadedButton()?.disabled).toBe(false); + }); +}); + +describe("UpdateDialog close protection", () => { + it("allows closing while a downloaded update is idle", async () => { + const { state } = await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 }); + + expect(buttonWithText("Cancel")).toBeDefined(); + expect(document.body.querySelector('[data-slot="dialog-close"]')).not.toBeNull(); + await pressEscape(); + + expect(state.open).toBe(false); + }); + + it("prevents closing while installation is in progress", async () => { + const { state } = await mountDialog(0, { updateDownloaded: true, isInstallingUpdate: true }); + + expect(buttonWithText("Cancel")).toBeUndefined(); + expect(document.body.querySelector('[data-slot="dialog-close"]')).toBeNull(); + await pressEscape(); + + expect(state.open).toBe(true); + }); + + it("allows closing after installation rejects", async () => { + let rejectInstall!: (error: Error) => void; + const installDownloaded = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectInstall = reject; + }), + ); + const { state } = await mountDialog(0, { updateDownloaded: true }, installDownloaded); + + installDownloadedButton()?.click(); + await flushDialog(); + await pressEscape(); + expect(state.open).toBe(true); + + rejectInstall(new Error("install failed")); + await flushDialog(); + expect(buttonWithText("Cancel")).toBeDefined(); + await pressEscape(); + + expect(state.open).toBe(false); + }); + + it("retries the retained download after reopening without downloading again", async () => { + const installDownloaded = vi.fn(async () => { + throw new Error("install failed"); + }); + const { state, downloadAndInstall } = await mountDialog(0, { updateDownloaded: true }, installDownloaded); + + installDownloadedButton()?.click(); + await flushDialog(); + await pressEscape(); + state.open = true; + await flushDialog(); + installDownloadedButton()?.click(); + await flushDialog(); + + expect(installDownloaded).toHaveBeenCalledTimes(2); + expect(downloadAndInstall).not.toHaveBeenCalled(); + }); + + it("keeps the successful install flow protected", async () => { + const installDownloaded = vi.fn(async () => {}); + const { state, downloadAndInstall } = await mountDialog(0, { updateDownloaded: true }, installDownloaded); + + installDownloadedButton()?.click(); + await flushDialog(); + + expect(state.updateDownloaded).toBe(false); + expect(state.updateReady).toBe(true); + expect(buttonWithText("Restart")).toBeDefined(); + expect(buttonWithText("Cancel")).toBeUndefined(); + await pressEscape(); + expect(state.open).toBe(true); + expect(installDownloaded).toHaveBeenCalledOnce(); + expect(downloadAndInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/layout/UpdateDialog.vue b/apps/desktop/src/components/layout/UpdateDialog.vue index f2e4f76c1b..93f658dda2 100644 --- a/apps/desktop/src/components/layout/UpdateDialog.vue +++ b/apps/desktop/src/components/layout/UpdateDialog.vue @@ -1,7 +1,7 @@