diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue
index 06d34e43ea..a3573e6816 100644
--- a/apps/desktop/src/App.vue
+++ b/apps/desktop/src/App.vue
@@ -207,7 +207,7 @@ const {
checkUpdates,
openLatestRelease,
ignoreCurrentVersion,
- downloadAndInstallUpdate,
+ downloadUpdateInBackground,
cancelDownload,
installDownloadedUpdate,
restartApp,
@@ -3011,6 +3011,10 @@ onUnmounted(() => {
:show-settings-page="showSettingsPage"
:checking-updates="checkingUpdates"
:has-update-available="toolbarHasUpdateAvailable"
+ :is-downloading-update="isDownloadingUpdate"
+ :download-progress="downloadProgress"
+ :update-ready-to-install="updateDownloaded"
+ :update-ready="updateReady"
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
:has-mcp-update-available="toolbarMcpUpdateAvailable"
:has-connections="connectionStore.connections.length > 0"
@@ -3343,7 +3347,7 @@ onUnmounted(() => {
:is-ignoring-update="isIgnoringUpdate"
:active-task-count="activeUpdateTaskCount"
@open-latest-release="openLatestRelease"
- @download-and-install="downloadAndInstallUpdate"
+ @download-in-background="downloadUpdateInBackground"
@cancel-download="cancelDownload"
@install-downloaded="installDownloadedUpdate"
@restart="restartApp"
diff --git a/apps/desktop/src/components/layout/AppToolbar.vue b/apps/desktop/src/components/layout/AppToolbar.vue
index f4336db32f..81a3622880 100644
--- a/apps/desktop/src/components/layout/AppToolbar.vue
+++ b/apps/desktop/src/components/layout/AppToolbar.vue
@@ -40,6 +40,10 @@ const props = defineProps<{
showSettingsPage: boolean;
checkingUpdates: boolean;
hasUpdateAvailable: boolean;
+ isDownloadingUpdate: boolean;
+ downloadProgress: number | null;
+ updateReadyToInstall: boolean;
+ updateReady: boolean;
agentDriverUpdateCount: number;
hasMcpUpdateAvailable: boolean;
hasConnections: boolean;
@@ -70,6 +74,14 @@ const settingsStore = useSettingsStore();
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
const checkingUpdates = computed(() => props.checkingUpdates);
+const updateTooltip = computed(() => {
+ if (props.isDownloadingUpdate) return t("updates.downloading", { progress: props.downloadProgress ?? 0 });
+ if (props.updateReady) return t("updates.restartRequiredTooltip");
+ if (props.updateReadyToInstall) return t("updates.updateReadyTooltip");
+ return t("updates.check");
+});
+// ToolbarUpdateIcon takes a 0..1 fraction, with null meaning the download size is unknown.
+const updateDownloadProgress = computed(() => (props.downloadProgress == null ? null : Math.min(1, Math.max(0, props.downloadProgress / 100))));
const sqlLibrarySaveFeedbackActive = ref(false);
const SQL_LIBRARY_BOOKMARK_PATH = "M10 2 L10 10 L13 7 L16 10 L16 2";
const SQL_LIBRARY_CHECK_PATH = "M9 9.5 L9 9.5 L11 11.5 L15 7.5 L15 7.5";
@@ -586,11 +598,12 @@ const toolbarStyle = computed(() => {
- {{ t("updates.check") }}
+ {{ updateTooltip }}
diff --git a/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue b/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue
index 03f0175aac..3cbbc5a466 100644
--- a/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue
+++ b/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue
@@ -1,14 +1,29 @@
-
+
+
@@ -32,16 +47,36 @@ defineProps<{
color: #fff;
}
+.toolbar-update-progress {
+ /* Same 1.75px ring the toolbar previously drew inline for background downloads. */
+ mask: radial-gradient(farthest-side, transparent calc(100% - 1.75px), #000 calc(100% - 1.75px));
+ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 1.75px), #000 calc(100% - 1.75px));
+}
+
+.toolbar-update-progress--indeterminate {
+ animation: toolbar-update-progress-spin 1100ms linear infinite;
+}
+
@keyframes toolbar-update-outline-scan {
to {
stroke-dashoffset: -43;
}
}
+@keyframes toolbar-update-progress-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
.toolbar-update-scan {
animation: none;
stroke-dasharray: none;
}
+
+ .toolbar-update-progress--indeterminate {
+ animation: none;
+ }
}
diff --git a/apps/desktop/src/components/layout/UpdateDialog.spec.ts b/apps/desktop/src/components/layout/UpdateDialog.spec.ts
index f5bbbddaa1..937e2717aa 100644
--- a/apps/desktop/src/components/layout/UpdateDialog.spec.ts
+++ b/apps/desktop/src/components/layout/UpdateDialog.spec.ts
@@ -16,7 +16,7 @@ interface DialogState {
portableMode: boolean;
manualUpdateOnly: boolean;
isDownloadingUpdate: boolean;
- downloadProgress: number;
+ downloadProgress: number | null;
updateDownloaded: boolean;
isInstallingUpdate: boolean;
updateReady: boolean;
@@ -41,7 +41,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial
diff --git a/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts b/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts
index 8c0330c697..98622a3c5a 100644
--- a/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts
@@ -34,4 +34,56 @@ describe("ToolbarUpdateIcon", () => {
expect(container.querySelector("[data-toolbar-update-cloud]")).not.toBeNull();
expect(container.querySelector("[data-toolbar-update-scan]")).not.toBeNull();
});
+
+ it("shows a progress ring while downloading, growing with progress", async () => {
+ const state = reactive({ downloading: true, progress: 0.42 });
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(
+ defineComponent({
+ setup: () => () => h(ToolbarUpdateIcon, { loading: false, downloading: state.downloading, progress: state.progress }),
+ }),
+ );
+ mountedApps.push(app);
+ app.mount(container);
+ await nextTick();
+
+ const ring = container.querySelector("[data-toolbar-update-progress]");
+ expect(ring).not.toBeNull();
+ expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(false);
+ expect(ring?.style.background).toBe("conic-gradient(var(--primary) 42%, transparent 42%)");
+ // The idle and checking glyphs stay hidden behind the ring.
+ expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
+ expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
+
+ state.progress = 1;
+ await nextTick();
+ expect(ring?.style.background).toBe("conic-gradient(var(--primary) 100%, transparent 100%)");
+
+ state.downloading = false;
+ await nextTick();
+ expect(container.querySelector("[data-toolbar-update-progress]")).toBeNull();
+ expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
+ });
+
+ it("falls back to a rotating indeterminate arc when the download size is unknown", async () => {
+ const state = reactive({ downloading: true, progress: null as number | null });
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(
+ defineComponent({
+ setup: () => () => h(ToolbarUpdateIcon, { loading: true, downloading: state.downloading, progress: state.progress }),
+ }),
+ );
+ mountedApps.push(app);
+ app.mount(container);
+ await nextTick();
+
+ const ring = container.querySelector("[data-toolbar-update-progress]");
+ expect(ring).not.toBeNull();
+ // Indeterminate downloading outranks the checking animation.
+ expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
+ expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(true);
+ expect(ring?.style.background).toBe("conic-gradient(var(--primary) 25%, transparent 25%)");
+ });
});
diff --git a/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts b/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
index e7f95f3c35..51be5cfd9f 100644
--- a/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
-import { createApp, defineComponent, h, type App } from "vue";
+import { createApp, defineComponent, h, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { useAppUpdater } from "@/composables/useAppUpdater";
@@ -9,6 +9,7 @@ const apiMock = vi.hoisted(() => ({
cancelUpdateDownload: vi.fn<() => Promise>(),
downloadUpdate: vi.fn<() => Promise>(),
installDownloadedUpdate: vi.fn<() => Promise>(),
+ checkForUpdates: vi.fn(),
}));
const listenMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => vi.fn());
@@ -50,6 +51,31 @@ function deferred(): Deferred {
let app: App | undefined;
let container: HTMLDivElement | undefined;
+function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
+ container = document.createElement("div");
+ document.body.append(container);
+ let updater!: ReturnType;
+ app = createApp(
+ defineComponent({
+ setup() {
+ updater = useAppUpdater(options);
+ return () => h("div");
+ },
+ }),
+ );
+ app.use(i18n);
+ app.mount(container);
+ updater.updateInfo.value = {
+ current_version: "0.5.69",
+ latest_version: "0.5.70",
+ update_available: true,
+ release_name: "DBX v0.5.70",
+ release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
+ release_notes: "",
+ };
+ return updater;
+}
+
beforeEach(() => {
vi.clearAllMocks();
settingsStoreMock.editorSettings.ignoredUpdateVersion = "";
@@ -73,35 +99,15 @@ describe("useAppUpdater download attempts", () => {
apiMock.downloadUpdate.mockImplementationOnce(() => firstDownload.promise).mockImplementationOnce(() => secondDownload.promise);
apiMock.cancelUpdateDownload.mockReturnValueOnce(cancellation.promise);
- let updater!: ReturnType;
- container = document.createElement("div");
- document.body.append(container);
- app = createApp(
- defineComponent({
- setup() {
- updater = useAppUpdater();
- return () => h("div");
- },
- }),
- );
- app.use(i18n);
- app.mount(container);
- updater.updateInfo.value = {
- current_version: "0.5.69",
- latest_version: "0.5.70",
- update_available: true,
- release_name: "DBX v0.5.70",
- release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
- release_notes: "",
- };
+ const updater = mountUpdater();
- const firstAttempt = updater.downloadAndInstallUpdate();
+ const firstAttempt = updater.downloadUpdateInBackground();
await vi.waitFor(() => expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1));
const cancelAttempt = updater.cancelDownload();
expect(updater.isDownloadingUpdate.value).toBe(false);
- const retryAttempt = updater.downloadAndInstallUpdate();
+ const retryAttempt = updater.downloadUpdateInBackground();
await Promise.resolve();
expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1);
expect(updater.isDownloadingUpdate.value).toBe(true);
@@ -113,40 +119,157 @@ describe("useAppUpdater download attempts", () => {
firstDownload.reject(new Error("Download canceled by user."));
await firstAttempt;
expect(updater.isDownloadingUpdate.value).toBe(true);
- expect(updater.downloadProgress.value).toBe(0);
+ // The retry is in flight with no progress event yet, so progress is indeterminate.
+ expect(updater.downloadProgress.value).toBeNull();
secondDownload.resolve();
await retryAttempt;
+ // No active tasks by default, so the successful download auto-installs and lands on
+ // "ready to restart" instead of waiting for a manual "Install Now" click.
expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce();
expect(updater.isDownloadingUpdate.value).toBe(false);
+ expect(updater.downloadProgress.value).toBe(100);
+ expect(updater.updateDownloaded.value).toBe(false);
expect(updater.updateReady.value).toBe(true);
+ expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) }));
+ });
+
+ it("auto-installs once the download finishes while idle, without waiting for a manual click", async () => {
+ apiMock.downloadUpdate.mockResolvedValueOnce();
+ const updater = mountUpdater();
+ updater.showUpdateDialog.value = true;
+
+ const attempt = updater.downloadUpdateInBackground();
+ expect(updater.showUpdateDialog.value).toBe(false);
+ await attempt;
+
+ expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce();
+ expect(updater.updateDownloaded.value).toBe(false);
+ expect(updater.updateReady.value).toBe(true);
+ expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) }));
+ });
+
+ it("falls back to a manual Install Now toast when tasks are still active once the download finishes", async () => {
+ apiMock.downloadUpdate.mockResolvedValueOnce();
+ // Must be a reactive ref, not a plain closure variable — useAppUpdater's activeTaskCount
+ // is a computed(), which only invalidates its cache when a tracked reactive source changes.
+ const activeTaskCount = ref(2);
+ const updater = mountUpdater({ getActiveTaskCount: () => activeTaskCount.value });
+
+ await updater.downloadUpdateInBackground();
+
+ expect(apiMock.installDownloadedUpdate).not.toHaveBeenCalled();
+ expect(updater.updateDownloaded.value).toBe(true);
+ expect(toastMock).toHaveBeenLastCalledWith("DBX v0.5.70 is ready to install.", 10000, expect.objectContaining({ label: "Install Now", onClick: expect.any(Function) }));
+
+ activeTaskCount.value = 0;
+ const [, , action] = toastMock.mock.lastCall!;
+ action.onClick();
+
+ await vi.waitFor(() => expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce());
+ await vi.waitFor(() => expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) })));
+ expect(updater.updateReady.value).toBe(true);
+ });
+
+ it("tracks download progress and stays indeterminate when the backend reports no total", async () => {
+ const download = deferred();
+ apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
+ const updater = mountUpdater();
+
+ const downloadAttempt = updater.downloadUpdateInBackground();
+ await vi.waitFor(() => expect(listenMock).toHaveBeenCalledOnce());
+ // No progress event yet — the size is unknown, so progress stays indeterminate.
+ expect(updater.downloadProgress.value).toBeNull();
+
+ const onProgress = listenMock.mock.calls[0][1] as (event: { payload: { downloaded: number; total: number | null } }) => void;
+ onProgress({ payload: { downloaded: 25, total: 100 } });
+ expect(updater.downloadProgress.value).toBe(25);
+
+ // Mirrors may stream chunks without a total; progress must not freeze at a stale percentage.
+ onProgress({ payload: { downloaded: 64, total: null } });
+ expect(updater.downloadProgress.value).toBeNull();
+
+ download.resolve();
+ await downloadAttempt;
+ expect(updater.downloadProgress.value).toBe(100);
});
});
-describe("useAppUpdater ignore version", () => {
- it("persists the ignored latest version and closes the update dialog", async () => {
- let updater!: ReturnType;
- container = document.createElement("div");
- document.body.append(container);
- app = createApp(
- defineComponent({
- setup() {
- updater = useAppUpdater();
- return () => h("div");
- },
- }),
- );
- app.use(i18n);
- app.mount(container);
- updater.updateInfo.value = {
+describe("useAppUpdater reopening the dialog from the toolbar", () => {
+ it("resurfaces the dialog without re-checking while a download is in progress", async () => {
+ const download = deferred();
+ apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
+ const updater = mountUpdater();
+
+ const downloadAttempt = updater.downloadUpdateInBackground();
+ await vi.waitFor(() => expect(updater.isDownloadingUpdate.value).toBe(true));
+
+ await updater.checkUpdates();
+
+ expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
+ expect(updater.showUpdateDialog.value).toBe(true);
+
+ download.resolve();
+ await downloadAttempt;
+ });
+
+ it("resurfaces the dialog without re-checking once the update is downloaded and ready to install", async () => {
+ apiMock.downloadUpdate.mockResolvedValueOnce();
+ // Busy at download-completion time so the install step stays pending instead of auto-running.
+ const updater = mountUpdater({ getActiveTaskCount: () => 1 });
+
+ await updater.downloadUpdateInBackground();
+ expect(updater.updateDownloaded.value).toBe(true);
+ expect(updater.showUpdateDialog.value).toBe(false);
+
+ await updater.checkUpdates();
+
+ expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
+ expect(updater.showUpdateDialog.value).toBe(true);
+ });
+
+ it("resurfaces the dialog without re-checking once the update is installed and awaiting restart", async () => {
+ apiMock.downloadUpdate.mockResolvedValueOnce();
+ const updater = mountUpdater();
+
+ await updater.downloadUpdateInBackground();
+ expect(updater.updateReady.value).toBe(true);
+
+ await updater.checkUpdates();
+
+ expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
+ expect(updater.showUpdateDialog.value).toBe(true);
+ });
+
+ it("still checks the network for a silent background check even mid-download", async () => {
+ const download = deferred();
+ apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
+ apiMock.checkForUpdates.mockResolvedValueOnce({
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
- };
+ });
+ const updater = mountUpdater();
+
+ const downloadAttempt = updater.downloadUpdateInBackground();
+ await vi.waitFor(() => expect(updater.isDownloadingUpdate.value).toBe(true));
+
+ await updater.checkUpdates({ silent: true });
+
+ expect(apiMock.checkForUpdates).toHaveBeenCalledOnce();
+
+ download.resolve();
+ await downloadAttempt;
+ });
+});
+
+describe("useAppUpdater ignore version", () => {
+ it("persists the ignored latest version and closes the update dialog", async () => {
+ const updater = mountUpdater();
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
@@ -158,27 +281,7 @@ describe("useAppUpdater ignore version", () => {
it("keeps the dialog open and allows retry when persistence fails", async () => {
settingsStoreMock.updateEditorSettingsAndPersist.mockRejectedValueOnce(new Error("storage unavailable")).mockResolvedValueOnce();
- let updater!: ReturnType;
- container = document.createElement("div");
- document.body.append(container);
- app = createApp(
- defineComponent({
- setup() {
- updater = useAppUpdater();
- return () => h("div");
- },
- }),
- );
- app.use(i18n);
- app.mount(container);
- updater.updateInfo.value = {
- current_version: "0.5.69",
- latest_version: "0.5.70",
- update_available: true,
- release_name: "DBX v0.5.70",
- release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
- release_notes: "",
- };
+ const updater = mountUpdater();
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts
index 9f2a326082..bf6c013c36 100644
--- a/apps/desktop/src/composables/useAppUpdater.ts
+++ b/apps/desktop/src/composables/useAppUpdater.ts
@@ -8,7 +8,7 @@ import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/sto
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
import { currentLocale } from "@/i18n";
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
-import { downloadAndInstallUpdateWhenIdle, installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
+import { installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
interface UseAppUpdaterOptions {
getActiveTaskCount?: () => number;
@@ -117,7 +117,8 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const updateCheckMessage = ref("");
const showUpdateDialog = ref(false);
const isDownloadingUpdate = ref(false);
- const downloadProgress = ref(0);
+ // null while the download size is unknown (backend reported no total) — the UI shows an indeterminate state.
+ const downloadProgress = ref(0);
const updateDownloaded = ref(false);
const isInstallingUpdate = ref(false);
const updateReady = ref(false);
@@ -138,6 +139,12 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
async function checkUpdates(options: { silent?: boolean } = {}) {
if (checkingUpdates.value) return;
+ // The toolbar icon reuses this as its click handler. If a download/install is already
+ // in flight or finished, just resurface the dialog instead of hitting the network again.
+ if (!options.silent && (isDownloadingUpdate.value || updateDownloaded.value || updateReady.value)) {
+ showUpdateDialog.value = true;
+ return;
+ }
checkingUpdates.value = true;
updateCheckMessage.value = "";
try {
@@ -201,18 +208,22 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
return true;
}
- async function downloadAndInstallUpdate() {
+ async function downloadUpdateInBackground() {
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || updateDownloaded.value || updateReady.value) return;
if (!canDownloadAndInstallUpdate(updateInfo.value, true)) {
openLatestRelease();
return;
}
- if (blockUpdateForActiveTasks()) return;
+ // Starting the download never has to wait on active work — only installing
+ // (file replacement) is disruptive, so that guard applies at install time instead.
+ showUpdateDialog.value = false;
const attempt = ++activeDownloadAttempt;
isDownloadingUpdate.value = true;
- downloadProgress.value = 0;
+ // The total is unknown until the first progress event arrives.
+ downloadProgress.value = null;
let unlisten: (() => void) | undefined;
const latestVersion = updateInfo.value?.latest_version;
+ toast(t("updates.downloadStarted", { version: latestVersion ? tagVersion(latestVersion) : "" }), 3000);
try {
await pendingCancellation;
if (attempt !== activeDownloadAttempt) return;
@@ -220,23 +231,27 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
unlisten = await listen("update-download-progress", (event) => {
if (attempt !== activeDownloadAttempt) return;
const total = event.payload.total ?? 0;
- downloadProgress.value = total > 0 ? Math.round((event.payload.downloaded / total) * 100) : 0;
+ downloadProgress.value = total > 0 ? Math.round((event.payload.downloaded / total) * 100) : null;
});
- const result = await downloadAndInstallUpdateWhenIdle({
- getActiveTaskCount: () => activeTaskCount.value,
- download: async () => {
- await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion);
- if (attempt !== activeDownloadAttempt) throw new Error("Download canceled by user.");
- downloadProgress.value = 100;
- updateDownloaded.value = true;
- isDownloadingUpdate.value = false;
- },
- install: async () => {
- await installPendingUpdate();
- },
- });
- if (result === "blocked" || result === "downloaded") {
- blockUpdateForActiveTasks();
+ await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion);
+ if (attempt !== activeDownloadAttempt) return;
+ downloadProgress.value = 100;
+ updateDownloaded.value = true;
+ // The download phase is truly finished — flip this now so the install guard (which
+ // checks isDownloadingUpdate) can evaluate freely instead of treating us as still busy.
+ isDownloadingUpdate.value = false;
+ if (shouldBlockAppUpdate(activeTaskCount.value)) {
+ // Matches the pre-refactor behavior: installing waits for active work to finish, but
+ // unlike before, only installing waits — the download itself never blocked on it.
+ toast(t("updates.readyToInstall", { version: latestVersion ? tagVersion(latestVersion) : "" }), 10000, {
+ label: t("updates.installNow"),
+ onClick: () => {
+ void installDownloadedUpdate();
+ },
+ });
+ } else {
+ // Restores the pre-refactor auto-install-when-idle behavior for the install step.
+ await installDownloadedUpdate();
}
} catch (e: any) {
if (attempt !== activeDownloadAttempt) return;
@@ -276,7 +291,18 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
getActiveTaskCount: () => activeTaskCount.value,
install: installPendingUpdate,
});
- if (!installed) blockUpdateForActiveTasks();
+ if (!installed) {
+ blockUpdateForActiveTasks();
+ return;
+ }
+ if (updateReady.value) {
+ toast(t("updates.installedReadyToRestart"), 10000, {
+ label: t("updates.restart"),
+ onClick: () => {
+ void restartApp();
+ },
+ });
+ }
} catch (e: any) {
toast(t("updates.installFailed", { error: e?.message || String(e) }), 5000);
}
@@ -331,7 +357,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
formatUpdateError,
openLatestRelease,
ignoreCurrentVersion,
- downloadAndInstallUpdate,
+ downloadUpdateInBackground,
cancelDownload,
installDownloadedUpdate,
restartApp,
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 560ef21447..b7ccb90525 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -282,6 +282,8 @@ export default {
updates: {
title: "Updates",
check: "Check for updates",
+ updateReadyTooltip: "Update ready — click to install",
+ restartRequiredTooltip: "Update installed — restart to finish",
availableTitle: "Update available",
availableMessage: "DBX {latest} is available. You are using {current}.",
upToDate: "DBX is up to date ({version}).",
@@ -291,13 +293,18 @@ export default {
ignoreVersion: "Ignore this version",
versionIgnored: "Version {version} ignored. You'll be reminded when the next version releases.",
ignoreVersionFailed: "Failed to save the ignored version: {error}",
- downloadAndInstall: "Download & Install",
+ downloadInBackground: "Download in Background",
+ cancelDownload: "Cancel Download",
+ downloadStarted: "Downloading DBX {version} in the background.",
activeTasksBlockUpdate: "{count} task(s) are still running. Wait for them to finish before updating DBX.",
portableAutomaticUpdate: "DBX will download the signed portable ZIP, replace only DBX.exe after exit, and restart automatically. portable.dbx and data will be kept.",
windows7ManualUpdate: "Windows 7 requires the dedicated WebView2 109 offline installer. Open the release page and download the Windows 7 package to update.",
downloading: "Downloading {progress}%",
downloadFailed: "Update download failed: {error}",
downloadCanceled: "Update download canceled.",
+ readyToInstall: "DBX {version} is ready to install.",
+ installNow: "Install Now",
+ installedReadyToRestart: "DBX has been updated. Restart to finish.",
networkOrMirrorFailed: "Network connection timed out or download mirrors are currently unreachable. Please check your connection or click 'Open Release' to download manually.",
installing: "Installing update...",
installFailed: "Update installation failed: {error}",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 44262393ad..4b51d45971 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -284,6 +284,8 @@ export default withEnglishFallback({
updates: {
title: "Actualizaciones",
check: "Buscar actualizaciones",
+ updateReadyTooltip: "Actualización lista, haz clic para instalar",
+ restartRequiredTooltip: "Actualización instalada, reinicia para finalizar",
availableTitle: "Actualización disponible",
availableMessage: "DBX {latest} está disponible. Estás usando la versión {current}.",
upToDate: "DBX está actualizado ({version}).",
@@ -293,13 +295,18 @@ export default withEnglishFallback({
ignoreVersion: "Ignorar esta versión",
versionIgnored: "Versión {version} ignorada. Se te recordará cuando se publique la próxima versión.",
ignoreVersionFailed: "No se pudo guardar la versión ignorada: {error}",
- downloadAndInstall: "Descargar e instalar",
+ downloadInBackground: "Descargar en segundo plano",
+ cancelDownload: "Cancelar descarga",
+ downloadStarted: "Descargando DBX {version} en segundo plano.",
activeTasksBlockUpdate: "Hay {count} tarea(s) en ejecución. Espera a que terminen antes de actualizar DBX.",
portableAutomaticUpdate: "DBX descargará el ZIP portable firmado, reemplazará solo DBX.exe después de salir y se reiniciará automáticamente. portable.dbx y data se conservarán.",
windows7ManualUpdate: "Windows 7 requiere el instalador sin conexión dedicado de WebView2 109. Abre la página de lanzamiento y descarga el paquete para Windows 7.",
downloading: "Descargando {progress}%",
downloadFailed: "Error al descargar la actualización: {error}",
downloadCanceled: "Descarga de actualización cancelada.",
+ readyToInstall: "DBX {version} está listo para instalar.",
+ installNow: "Instalar ahora",
+ installedReadyToRestart: "DBX se ha actualizado. Reinicia para finalizar.",
networkOrMirrorFailed: "El tiempo de espera de la conexión de red se ha agotado o los espejos de descarga no están disponibles. Compruebe su red o haga clic en 'Abrir lanzamiento' para descargar manualmente.",
installing: "Instalando actualización...",
installFailed: "Error al instalar la actualización: {error}",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index e20c620934..b32a6a2a69 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -283,6 +283,8 @@ export default withEnglishFallback({
updates: {
title: "Aggiornamenti",
check: "Verifica aggiornamenti",
+ updateReadyTooltip: "Aggiornamento pronto, clicca per installare",
+ restartRequiredTooltip: "Aggiornamento installato, riavvia per completare",
availableTitle: "Aggiornamento disponibile",
availableMessage: "DBX {latest} è disponibile. Stai usando la versione {current}.",
upToDate: "DBX è aggiornato ({version}).",
@@ -292,13 +294,18 @@ export default withEnglishFallback({
ignoreVersion: "Ignora questa versione",
versionIgnored: "Versione {version} ignorata. Verrai avvisato al rilascio della prossima versione.",
ignoreVersionFailed: "Impossibile salvare la versione ignorata: {error}",
- downloadAndInstall: "Scarica e Installa",
+ downloadInBackground: "Scarica in background",
+ cancelDownload: "Annulla download",
+ downloadStarted: "Download di DBX {version} in background in corso.",
activeTasksBlockUpdate: "Ci sono {count} attività in esecuzione. Attendi che terminino prima di aggiornare DBX.",
portableAutomaticUpdate: "DBX scaricherà lo ZIP portatile firmato, sostituirà solo DBX.exe dopo l'uscita e si riavvierà automaticamente. portable.dbx e i dati verranno mantenuti.",
windows7ManualUpdate: "Windows 7 richiede il programma di installazione offline dedicato WebView2 109. Apri la pagina della release e scarica il pacchetto per Windows 7.",
downloading: "Download in corso {progress}%",
downloadFailed: "Download dell'aggiornamento non riuscito: {error}",
downloadCanceled: "Download dell'aggiornamento annullato.",
+ readyToInstall: "DBX {version} è pronto per l'installazione.",
+ installNow: "Installa ora",
+ installedReadyToRestart: "DBX è stato aggiornato. Riavvia per completare.",
networkOrMirrorFailed: "Connessione di rete scaduta o server mirror temporaneamente non disponibili. Controlla la rete o fai clic su 'Apri Release' per scaricare manualmente.",
installing: "Installazione dell'aggiornamento...",
installFailed: "Installazione dell'aggiornamento non riuscita: {error}",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index ef7167a846..963652b603 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -284,6 +284,8 @@ export default withEnglishFallback({
updates: {
title: "アップデート",
check: "アップデートを確認",
+ updateReadyTooltip: "アップデートの準備ができました。クリックしてインストール",
+ restartRequiredTooltip: "アップデートがインストールされました。再起動して完了してください",
availableTitle: "アップデートがあります",
availableMessage: "DBX {latest} が利用可能です。現在のバージョンは {current} です。",
upToDate: "DBX は最新です ({version})。",
@@ -293,13 +295,18 @@ export default withEnglishFallback({
ignoreVersion: "このバージョンを無視",
versionIgnored: "バージョン {version} を無視しました。次のバージョンがリリースされたときに再度通知します。",
ignoreVersionFailed: "無視するバージョンを保存できませんでした: {error}",
- downloadAndInstall: "ダウンロード & インストール",
+ downloadInBackground: "バックグラウンドでダウンロード",
+ cancelDownload: "ダウンロードをキャンセル",
+ downloadStarted: "DBX {version} をバックグラウンドでダウンロード中です。",
activeTasksBlockUpdate: "{count} 件のタスクが実行中です。完了してから DBX を更新してください。",
portableAutomaticUpdate: "DBX は署名済みのポータブル ZIP をダウンロードし、終了後に DBX.exe のみを置き換えて自動的に再起動します。portable.dbx とデータは保持されます。",
windows7ManualUpdate: "Windows 7 では専用の WebView2 109 オフラインインストーラーが必要です。リリースページから Windows 7 用パッケージをダウンロードして更新してください。",
downloading: "ダウンロード中 {progress}%",
downloadFailed: "アップデートのダウンロードに失敗しました: {error}",
downloadCanceled: "アップデートのダウンロードがキャンセルされました。",
+ readyToInstall: "DBX {version} のインストール準備ができました。",
+ installNow: "今すぐインストール",
+ installedReadyToRestart: "DBX が更新されました。再起動して完了してください。",
networkOrMirrorFailed: "ネットワーク接続がタイムアウトしたか、ミラーサーバーにアクセスできません。ネットワークを確認するか、「リリースページを開く」をクリックして手動でダウンロードしてください。",
installing: "アップデートをインストール中...",
installFailed: "アップデートのインストールに失敗しました: {error}",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index b4d60af06d..a1b52ecdda 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -282,6 +282,8 @@ export default withEnglishFallback({
updates: {
title: "업데이트",
check: "업데이트 확인",
+ updateReadyTooltip: "업데이트 준비 완료 — 클릭하여 설치",
+ restartRequiredTooltip: "업데이트 설치 완료 — 다시 시작하여 완료하세요",
availableTitle: "업데이트 가능",
availableMessage: "DBX {latest}을(를) 사용할 수 있습니다. 현재 {current}을(를) 사용 중입니다.",
upToDate: "DBX가 최신 버전입니다 ({version}).",
@@ -291,13 +293,18 @@ export default withEnglishFallback({
ignoreVersion: "이 버전 무시",
versionIgnored: "{version} 버전을 무시했습니다. 다음 버전이 출시되면 다시 알려드립니다.",
ignoreVersionFailed: "무시할 버전을 저장하지 못했습니다: {error}",
- downloadAndInstall: "다운로드 및 설치",
+ downloadInBackground: "백그라운드에서 다운로드",
+ cancelDownload: "다운로드 취소",
+ downloadStarted: "DBX {version}을(를) 백그라운드에서 다운로드하고 있습니다.",
activeTasksBlockUpdate: "{count}개의 작업이 아직 실행 중입니다. DBX를 업데이트하려면 작업이 완료될 때까지 기다려 주세요.",
portableAutomaticUpdate: "DBX가 서명된 휴대용 ZIP을 다운로드하여 종료 후 DBX.exe만 교체하고 자동으로 다시 시작합니다. portable.dbx와 데이터는 보존됩니다.",
windows7ManualUpdate: "Windows 7은 전용 WebView2 109 오프라인 설치 프로그램이 필요합니다. 릴리스 페이지를 열어 Windows 7 패키지를 다운로드하여 업데이트하세요.",
downloading: "다운로드 중 {progress}%",
downloadFailed: "업데이트 다운로드에 실패했습니다: {error}",
downloadCanceled: "업데이트 다운로드가 취소되었습니다.",
+ readyToInstall: "DBX {version} 설치 준비가 완료되었습니다.",
+ installNow: "지금 설치",
+ installedReadyToRestart: "DBX가 업데이트되었습니다. 다시 시작하여 완료하세요.",
networkOrMirrorFailed: "네트워크 연결 시간이 초과되었거나 다운로드 미러에 연결할 수 없습니다. 네트워크를 확인하거나 '릴리스 열기'를 클릭하여 수동으로 다운로드하세요.",
installing: "업데이트 설치 중...",
installFailed: "업데이트 설치에 실패했습니다: {error}",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index ad9a1a53b8..a47b52922b 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -284,6 +284,8 @@ export default withEnglishFallback({
updates: {
title: "Atualizações",
check: "Verificar atualizações",
+ updateReadyTooltip: "Atualização pronta, clique para instalar",
+ restartRequiredTooltip: "Atualização instalada, reinicie para concluir",
availableTitle: "Atualização disponível",
availableMessage: "DBX {latest} está disponível. Você está usando {current}.",
upToDate: "O DBX está atualizado ({version}).",
@@ -293,13 +295,18 @@ export default withEnglishFallback({
ignoreVersion: "Ignorar esta versão",
versionIgnored: "Versão {version} ignorada. Você será lembrado quando a próxima versão for lançada.",
ignoreVersionFailed: "Não foi possível salvar a versão ignorada: {error}",
- downloadAndInstall: "Baixar e Instalar",
+ downloadInBackground: "Baixar em segundo plano",
+ cancelDownload: "Cancelar download",
+ downloadStarted: "Baixando o DBX {version} em segundo plano.",
activeTasksBlockUpdate: "Há {count} tarefa(s) em execução. Aguarde a conclusão antes de atualizar o DBX.",
portableAutomaticUpdate: "O DBX baixará o ZIP portátil assinado, substituirá apenas o DBX.exe após sair e reiniciará automaticamente. O portable.dbx e os dados serão mantidos.",
windows7ManualUpdate: "O Windows 7 requer o instalador offline dedicado do WebView2 109. Abra a página da versão e baixe o pacote para Windows 7.",
downloading: "Baixando {progress}%",
downloadFailed: "Falha ao baixar a atualização: {error}",
downloadCanceled: "Download da atualização cancelado.",
+ readyToInstall: "DBX {version} está pronto para instalar.",
+ installNow: "Instalar agora",
+ installedReadyToRestart: "O DBX foi atualizado. Reinicie para concluir.",
networkOrMirrorFailed: "O tempo de conexão esgotou ou os espelhos de download estão indisponíveis. Verifique sua rede ou clique em 'Abrir Lançamento' para baixar manualmente.",
installing: "Instalando atualização...",
installFailed: "Falha ao instalar a atualização: {error}",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 76240a9abb..086dfd0c16 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -207,6 +207,8 @@ export default withEnglishFallback({
updates: {
title: "更新",
check: "检查更新",
+ updateReadyTooltip: "更新已就绪,点击安装",
+ restartRequiredTooltip: "更新已安装,重启以完成更新",
availableTitle: "发现新版本",
availableMessage: "DBX {latest} 已发布,当前版本为 {current}。",
upToDate: "DBX 已是最新版本 ({version})。",
@@ -216,13 +218,18 @@ export default withEnglishFallback({
ignoreVersion: "忽略此版本",
versionIgnored: "已忽略 {version},将在下个版本发布时再次提醒。",
ignoreVersionFailed: "保存忽略版本失败:{error}",
- downloadAndInstall: "下载并安装",
+ downloadInBackground: "后台下载",
+ cancelDownload: "取消下载",
+ downloadStarted: "正在后台下载 DBX {version}。",
activeTasksBlockUpdate: "有 {count} 个任务正在执行,请等待任务完成后再更新 DBX。",
portableAutomaticUpdate: "DBX 将下载已签名的便携版 ZIP,退出后仅替换 DBX.exe 并自动重启。portable.dbx 和 data 会保留。",
windows7ManualUpdate: "Windows 7 需要使用专用的 WebView2 109 离线安装包更新。请打开下载页并下载 Windows 7 安装包。",
downloading: "下载中 {progress}%",
downloadFailed: "更新下载失败:{error}",
downloadCanceled: "已取消更新下载。",
+ readyToInstall: "DBX {version} 已下载完成,可以安装了。",
+ installNow: "立即安装",
+ installedReadyToRestart: "DBX 已更新完成,重启后生效。",
networkOrMirrorFailed: "网络连接超时或所有镜像源暂不可用。请检查网络状态,或点击“打开下载页”手动下载。",
installing: "正在安装更新...",
installFailed: "更新安装失败:{error}",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index d2677a8ac2..11350c2f8b 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -284,6 +284,8 @@ export default withEnglishFallback({
updates: {
title: "更新",
check: "檢查更新",
+ updateReadyTooltip: "更新已就緒,點擊安裝",
+ restartRequiredTooltip: "更新已安裝,重新啟動以完成更新",
availableTitle: "發現新版本",
availableMessage: "DBX {latest} 已發布,目前版本為 {current}。",
upToDate: "DBX 已是最新版本 ({version})。",
@@ -293,13 +295,18 @@ export default withEnglishFallback({
ignoreVersion: "忽略此版本",
versionIgnored: "已忽略 {version},將在下個版本發布時再次提醒。",
ignoreVersionFailed: "儲存忽略版本失敗:{error}",
- downloadAndInstall: "下載並安裝",
+ downloadInBackground: "背景下載",
+ cancelDownload: "取消下載",
+ downloadStarted: "正在背景下載 DBX {version}。",
activeTasksBlockUpdate: "有 {count} 個任務正在執行,請等待任務完成後再更新 DBX。",
portableAutomaticUpdate: "DBX 將下載已簽署的可攜版 ZIP,結束後僅替換 DBX.exe 並自動重新啟動。portable.dbx 和 data 會保留。",
windows7ManualUpdate: "Windows 7 需要使用專用的 WebView2 109 離線安裝套件更新。請開啟下載頁並下載 Windows 7 安裝套件。",
downloading: "下載中 {progress}%",
downloadFailed: "更新下載失敗:{error}",
downloadCanceled: "已取消更新下載。",
+ readyToInstall: "DBX {version} 已下載完成,可以安裝了。",
+ installNow: "立即安裝",
+ installedReadyToRestart: "DBX 已更新完成,重新啟動後生效。",
networkOrMirrorFailed: "網路連接超時或所有鏡像源暫不可用。請檢查網路狀態,或點擊“開啟下載頁”手動下載。",
installing: "正在安裝更新...",
installFailed: "更新安裝失敗:{error}",
diff --git a/apps/desktop/src/lib/app/appUpdateInstallFlow.ts b/apps/desktop/src/lib/app/appUpdateInstallFlow.ts
index 80d0b37765..ecee3d8e7c 100644
--- a/apps/desktop/src/lib/app/appUpdateInstallFlow.ts
+++ b/apps/desktop/src/lib/app/appUpdateInstallFlow.ts
@@ -1,28 +1,10 @@
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
-export type UpdateDownloadFlowResult = "blocked" | "downloaded" | "installed";
-
-interface UpdateDownloadFlowOptions {
- getActiveTaskCount: () => number;
- download: () => Promise;
- install: () => Promise;
-}
-
interface DownloadedUpdateInstallOptions {
getActiveTaskCount: () => number;
install: () => Promise;
}
-export async function downloadAndInstallUpdateWhenIdle(options: UpdateDownloadFlowOptions): Promise {
- if (shouldBlockAppUpdate(options.getActiveTaskCount())) return "blocked";
-
- await options.download();
- if (shouldBlockAppUpdate(options.getActiveTaskCount())) return "downloaded";
-
- await options.install();
- return "installed";
-}
-
export async function installDownloadedUpdateWhenIdle(options: DownloadedUpdateInstallOptions): Promise {
if (shouldBlockAppUpdate(options.getActiveTaskCount())) return false;
diff --git a/packages/app-tests/appUpdater.test.ts b/packages/app-tests/appUpdater.test.ts
index 7e7e784ed0..3cb5642436 100644
--- a/packages/app-tests/appUpdater.test.ts
+++ b/packages/app-tests/appUpdater.test.ts
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { test } from "vitest";
import { canDownloadAndInstallUpdate, isUpdateIgnored, normalizeUpdateDownloadSource, resolveUpdateReleaseUrl, tagVersion } from "../../apps/desktop/src/composables/useAppUpdater.ts";
-import { downloadAndInstallUpdateWhenIdle, installDownloadedUpdateWhenIdle } from "../../apps/desktop/src/lib/app/appUpdateInstallFlow.ts";
+import { installDownloadedUpdateWhenIdle } from "../../apps/desktop/src/lib/app/appUpdateInstallFlow.ts";
import { countActiveUpdateBlockingTasks, shouldBlockAppUpdate } from "../../apps/desktop/src/lib/app/appUpdateTaskGuard.ts";
import type { UpdateInfo } from "../../apps/desktop/src/lib/backend/api.ts";
@@ -13,6 +13,7 @@ function updateInfo(overrides: Partial = {}): UpdateInfo {
latest_version: "0.5.26",
update_available: true,
portable_mode: false,
+ manual_update_only: false,
release_name: "DBX v0.5.26",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.26",
release_notes: "",
@@ -93,50 +94,37 @@ test("counts background and query tasks that must finish before updating", () =>
assert.equal(shouldBlockAppUpdate(1), true);
});
-test("retains a downloaded update when a task starts during download and installs it later without downloading again", async () => {
+test("blocks installing an already-downloaded update while tasks are active, then allows it once idle", async () => {
let activeTaskCount = 0;
- let downloadCount = 0;
let installCount = 0;
- let finishDownload!: () => void;
- const downloadGate = new Promise((resolve) => {
- finishDownload = resolve;
- });
const operations = {
getActiveTaskCount: () => activeTaskCount,
- download: async () => {
- downloadCount += 1;
- await downloadGate;
- },
install: async () => {
installCount += 1;
},
};
- const firstAttempt = downloadAndInstallUpdateWhenIdle(operations);
- assert.equal(downloadCount, 1);
- assert.equal(installCount, 0);
-
activeTaskCount = 1;
- finishDownload();
- assert.equal(await firstAttempt, "downloaded");
+ assert.equal(await installDownloadedUpdateWhenIdle(operations), false);
assert.equal(installCount, 0);
activeTaskCount = 0;
assert.equal(await installDownloadedUpdateWhenIdle(operations), true);
- assert.equal(downloadCount, 1);
assert.equal(installCount, 1);
});
-test("wires the active task guard into update installation and restart", () => {
+test("wires the active task guard into update installation and restart, but not into starting a background download", () => {
const appSource = readFileSync("apps/desktop/src/App.vue", "utf8");
const updaterSource = readFileSync("apps/desktop/src/composables/useAppUpdater.ts", "utf8");
const dialogSource = readFileSync("apps/desktop/src/components/layout/UpdateDialog.vue", "utf8");
assert.match(appSource, /countActiveUpdateBlockingTasks\(activeBackgroundTaskCount\.value, queryStore\.tabs\)/);
assert.match(appSource, /getActiveTaskCount: \(\) => trackedUpdateTaskCount\.value/);
- assert.equal(updaterSource.match(/if \(blockUpdateForActiveTasks\(\)\) return;/g)?.length, 2);
+ // Only restarting/exiting to finish the install has to wait on active work;
+ // starting the background download itself never blocks.
+ assert.equal(updaterSource.match(/if \(blockUpdateForActiveTasks\(\)\) return;/g)?.length, 1);
assert.match(dialogSource, /role="alert"[\s\S]*updates\.activeTasksBlockUpdate/);
- assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0"/g)?.length, 3);
+ assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0"/g)?.length, 2);
assert.match(dialogSource, /ignore-version/);
assert.match(updaterSource, /ignoreCurrentVersion/);
});