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
@@ -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%)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user