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

@@ -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%)");
});
});