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 @@