feat(update): download app updates in the background
This commit is contained in:
1 parent
d79d357924
commit
8be503bf40
18 files changed
+448
-171
No files matched your search
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user