feat(update): download app updates in the background

This commit is contained in:
0xTyson authored and GitHub committed 2026-09-01 11:09:44 +08:00
1 parent d79d357924
commit 8be503bf40
18 files changed
+448 -171

No files matched your search

+6 -2
View File
@@ -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"
@@ -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(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :disabled="checkingUpdates" @click="emit('check-updates')">
<ToolbarUpdateIcon :loading="checkingUpdates" />
<span v-if="hasUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
<ToolbarUpdateIcon :loading="checkingUpdates" :downloading="isDownloadingUpdate" :progress="updateDownloadProgress" />
<span v-if="updateReady || updateReadyToInstall" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-background" />
<span v-else-if="hasUpdateAvailable && !isDownloadingUpdate" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("updates.check") }}</TooltipContent>
<TooltipContent>{{ updateTooltip }}</TooltipContent>
</Tooltip>
</template>
@@ -1,14 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
import { Cloud, CloudDownload } from "@lucide/vue";
defineProps<{
const props = defineProps<{
loading: boolean;
/** Whether an update is downloading; takes precedence over `loading`. */
downloading?: boolean;
/** Download completion in 0..1, or null when the backend reports no total. */
progress?: number | null;
}>();
const progressRingStyle = computed(() => {
// Without a known total the ring cannot grow, so render a rotating partial arc
// instead of an invisible 0% one.
const percent = props.progress == null ? 25 : Math.round(Math.min(1, Math.max(0, props.progress)) * 100);
return {
background: `conic-gradient(var(--primary) ${percent}%, transparent ${percent}%)`,
};
});
</script>
<template>
<span data-toolbar-update-icon class="relative block h-4 w-4" :class="{ 'toolbar-update-icon--loading': loading }" aria-hidden="true">
<CloudDownload v-if="!loading" data-toolbar-update-idle class="toolbar-update-idle h-4 w-4" />
<span v-if="downloading" data-toolbar-update-progress class="toolbar-update-progress absolute inset-0 rounded-full" :class="{ 'toolbar-update-progress--indeterminate': progress == null }" :style="progressRingStyle" />
<CloudDownload v-else-if="!loading" data-toolbar-update-idle class="toolbar-update-idle h-4 w-4" />
<template v-else>
<Cloud data-toolbar-update-cloud class="toolbar-update-cloud absolute inset-0 h-4 w-4" />
<Cloud data-toolbar-update-scan class="toolbar-update-scan absolute inset-0 h-4 w-4" />
@@ -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;
}
}
</style>
+36 -17
View File
@@ -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<Dialog
isIgnoringUpdate: false,
...initialState,
});
const downloadAndInstall = vi.fn();
const downloadInBackground = vi.fn();
const cancelDownload = vi.fn();
const ignoreVersion = vi.fn();
const container = document.createElement("div");
@@ -86,7 +86,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
updateReady: state.updateReady,
isIgnoringUpdate: state.isIgnoringUpdate,
activeTaskCount,
"onDownload-and-install": downloadAndInstall,
"onDownload-in-background": downloadInBackground,
"onCancel-download": cancelDownload,
"onInstall-downloaded": handleInstallDownloaded,
"onIgnore-version": ignoreVersion,
@@ -99,7 +99,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
app.mount(container);
await flushDialog();
return { state, downloadAndInstall, cancelDownload, installDownloaded, ignoreVersion };
return { state, downloadInBackground, cancelDownload, installDownloaded, ignoreVersion };
}
function buttonWithText(text: string): HTMLButtonElement | undefined {
@@ -107,7 +107,11 @@ function buttonWithText(text: string): HTMLButtonElement | undefined {
}
function downloadButton(): HTMLButtonElement | undefined {
return buttonWithText("Download & Install");
return buttonWithText("Download in Background");
}
function cancelDownloadButton(): HTMLButtonElement | undefined {
return buttonWithText("Cancel Download");
}
function installDownloadedButton(): HTMLButtonElement | undefined {
@@ -130,11 +134,11 @@ afterEach(() => {
});
describe("UpdateDialog active task guard", () => {
it("shows the task warning and disables installation while work is running", async () => {
it("shows the task warning but still allows starting a background download while work is running", async () => {
await mountDialog(2);
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("2");
expect(downloadButton()?.disabled).toBe(true);
expect(downloadButton()?.disabled).toBe(false);
});
it("allows installation after all tasks finish", async () => {
@@ -193,36 +197,51 @@ describe("UpdateDialog download progress", () => {
expect(buttonWithText("Downloading 100%")?.classList.contains("w-52")).toBe(true);
});
it("falls back to 0% while the download size is unknown", async () => {
await mountDialog(0, { isDownloadingUpdate: true, downloadProgress: null });
expect(buttonWithText("Downloading 0%")).toBeDefined();
});
});
describe("UpdateDialog close protection", () => {
it("cancels the background download when the close button is clicked", async () => {
it("closes without cancelling the background download when the close button is clicked", async () => {
const { state, cancelDownload } = await mountDialog(0, { isDownloadingUpdate: true, downloadProgress: 42 });
const closeButton = document.body.querySelector<HTMLButtonElement>('[data-slot="dialog-close"]');
closeButton?.click();
await flushDialog();
expect(cancelDownload).toHaveBeenCalledOnce();
expect(cancelDownload).not.toHaveBeenCalled();
expect(state.open).toBe(false);
});
it("keeps downloading in the background when the dialog is dismissed by clicking outside", async () => {
it("closes without cancelling the background download when dismissed by clicking outside", async () => {
const { state, cancelDownload } = await mountDialog(0, { isDownloadingUpdate: true, downloadProgress: 42 });
await clickOutside();
expect(cancelDownload).not.toHaveBeenCalled();
expect(state.open).toBe(true);
expect(state.open).toBe(false);
});
it("keeps downloading in the background when the dialog is dismissed with Escape", async () => {
it("closes without cancelling the background download when dismissed with Escape", async () => {
const { state, cancelDownload } = await mountDialog(0, { isDownloadingUpdate: true, downloadProgress: 42 });
await pressEscape();
expect(cancelDownload).not.toHaveBeenCalled();
expect(state.open).toBe(true);
expect(state.open).toBe(false);
});
it("cancels the download only when the explicit Cancel Download button is clicked", async () => {
const { cancelDownload } = await mountDialog(0, { isDownloadingUpdate: true, downloadProgress: 42 });
cancelDownloadButton()?.click();
await flushDialog();
expect(cancelDownload).toHaveBeenCalledOnce();
});
it("allows closing while a downloaded update is idle", async () => {
@@ -272,7 +291,7 @@ describe("UpdateDialog close protection", () => {
const installDownloaded = vi.fn(async () => {
throw new Error("install failed");
});
const { state, downloadAndInstall } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
const { state, downloadInBackground } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
installDownloadedButton()?.click();
await flushDialog();
@@ -283,12 +302,12 @@ describe("UpdateDialog close protection", () => {
await flushDialog();
expect(installDownloaded).toHaveBeenCalledTimes(2);
expect(downloadAndInstall).not.toHaveBeenCalled();
expect(downloadInBackground).not.toHaveBeenCalled();
});
it("allows dismissing after a successful install while restart remains available", async () => {
const installDownloaded = vi.fn(async () => {});
const { state, downloadAndInstall } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
const { state, downloadInBackground } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
installDownloadedButton()?.click();
await flushDialog();
@@ -300,7 +319,7 @@ describe("UpdateDialog close protection", () => {
await pressEscape();
expect(state.open).toBe(false);
expect(installDownloaded).toHaveBeenCalledOnce();
expect(downloadAndInstall).not.toHaveBeenCalled();
expect(downloadInBackground).not.toHaveBeenCalled();
});
});
@@ -14,7 +14,7 @@ const props = defineProps<{
updateInfo: UpdateInfo | null;
updateCheckMessage: string;
isDownloadingUpdate: boolean;
downloadProgress: number;
downloadProgress: number | null;
updateDownloaded: boolean;
isInstallingUpdate: boolean;
updateReady: boolean;
@@ -24,7 +24,7 @@ const props = defineProps<{
const emit = defineEmits<{
"open-latest-release": [];
"download-and-install": [];
"download-in-background": [];
"cancel-download": [];
"install-downloaded": [];
restart: [];
@@ -35,11 +35,10 @@ const { t } = useI18n();
const isDesktop = isTauriRuntime();
const renderedNotes = ref("");
// Only active file replacement (installation) must trap the dialog.
// Only active file replacement (installation) must trap the dialog. A background
// download survives the dialog closing, so closing it never cancels the download.
const isCloseBlocked = computed(() => props.isInstallingUpdate);
// Accidental dismiss gestures (outside click, Escape) must not cancel a running download;
// only the explicit close/cancel buttons should.
const blocksImplicitDismiss = computed(() => props.isInstallingUpdate || props.isDownloadingUpdate);
const blocksImplicitDismiss = computed(() => props.isInstallingUpdate);
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.updateDownloaded && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
function handleCancel() {
@@ -52,9 +51,6 @@ function handleOpenChange(nextOpen: boolean) {
return;
}
if (isCloseBlocked.value) return;
if (props.isDownloadingUpdate) {
emit("cancel-download");
}
open.value = false;
}
@@ -156,12 +152,15 @@ watch(
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.installing") }}
</Button>
<Button v-else-if="isDownloadingUpdate" class="w-52 shrink-0 tabular-nums" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.downloading", { progress: downloadProgress }) }}
</Button>
<template v-else-if="isDownloadingUpdate">
<Button variant="ghost" class="shrink-0" @click="emit('cancel-download')">{{ t("updates.cancelDownload") }}</Button>
<Button class="w-52 shrink-0 tabular-nums" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.downloading", { progress: downloadProgress ?? 0 }) }}
</Button>
</template>
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('install-downloaded')">{{ t("updates.exitAndUpdate") }}</Button>
<Button v-else class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
<Button v-else class="shrink-0" @click="emit('download-in-background')">{{ t("updates.downloadInBackground") }}</Button>
</template>
</template>
<template v-else>
@@ -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<HTMLElement>("[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<HTMLElement>("[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%)");
});
});
+166 -63
View File
@@ -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<void>>(),
downloadUpdate: vi.fn<() => Promise<void>>(),
installDownloadedUpdate: vi.fn<() => Promise<void>>(),
checkForUpdates: vi.fn(),
}));
const listenMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => vi.fn());
@@ -50,6 +51,31 @@ function deferred<T>(): Deferred<T> {
let app: App | undefined;
let container: HTMLDivElement | undefined;
function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
container = document.createElement("div");
document.body.append(container);
let updater!: ReturnType<typeof useAppUpdater>;
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<typeof useAppUpdater>;
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<void>();
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<typeof useAppUpdater>;
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<void>();
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<void>();
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<typeof useAppUpdater>;
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();
+49 -23
View File
@@ -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<number | null>(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<UpdateDownloadProgress>("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,
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
+8 -1
View File
@@ -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}",
@@ -1,28 +1,10 @@
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
export type UpdateDownloadFlowResult = "blocked" | "downloaded" | "installed";
interface UpdateDownloadFlowOptions {
getActiveTaskCount: () => number;
download: () => Promise<void>;
install: () => Promise<void>;
}
interface DownloadedUpdateInstallOptions {
getActiveTaskCount: () => number;
install: () => Promise<void>;
}
export async function downloadAndInstallUpdateWhenIdle(options: UpdateDownloadFlowOptions): Promise<UpdateDownloadFlowResult> {
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<boolean> {
if (shouldBlockAppUpdate(options.getActiveTaskCount())) return false;