feat(updater): download and prepare desktop updates in background

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 11:44:18 +08:00
1 parent 7affad8ae0
commit e96af4aa30
77 files changed
+10536 -814

No files matched your search

@@ -40,6 +40,7 @@ const props = defineProps<{
showDriverStore: boolean;
showSettingsPage: boolean;
checkingUpdates: boolean;
updateVersion?: string;
hasUpdateAvailable: boolean;
isDownloadingUpdate: boolean;
downloadProgress: number | null;
@@ -75,15 +76,12 @@ const { toast } = useToast();
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");
if (props.hasUpdateAvailable && props.updateReady) return t("updates.restartRequiredTooltip");
if (props.hasUpdateAvailable && props.updateReadyToInstall) return t("updates.downloadedReady", { version: props.updateVersion ?? "" });
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";
@@ -199,7 +197,7 @@ const collapsibleRightItemDefs = computed(() => {
label: t("updates.check"),
icon: CloudDownload,
action: () => emit("check-updates"),
disabled: checkingUpdates.value,
disabled: false,
});
}
items.push({
@@ -612,10 +610,9 @@ const toolbarStyle = computed(() => {
<template v-if="toolbarItems.checkUpdates">
<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" :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 v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" @click="emit('check-updates')">
<ToolbarUpdateIcon />
<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" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ updateTooltip }}</TooltipContent>
@@ -1,82 +1,11 @@
<script setup lang="ts">
import { computed } from "vue";
import { Cloud, CloudDownload } from "@lucide/vue";
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}%)`,
};
});
import { CloudDownload } from "@lucide/vue";
// Background checking/downloading deliberately never animates the toolbar.
defineProps<{ loading?: boolean; downloading?: boolean; progress?: number | null }>();
</script>
<template>
<span data-toolbar-update-icon class="relative block h-4 w-4" :class="{ 'toolbar-update-icon--loading': loading }" aria-hidden="true">
<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" />
</template>
<span data-toolbar-update-icon class="relative block h-4 w-4" aria-hidden="true">
<CloudDownload data-toolbar-update-idle class="h-4 w-4" />
</span>
</template>
<style scoped>
.toolbar-update-cloud {
opacity: 0.38;
}
.toolbar-update-scan {
color: #000;
stroke-dasharray: 7 36;
stroke-dashoffset: 0;
animation: toolbar-update-outline-scan 1100ms linear infinite;
}
:global(.dark) .toolbar-update-scan {
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>
+18 -5
View File
@@ -14,6 +14,7 @@ const mountedApps: App[] = [];
interface DialogState {
open: boolean;
portableMode: boolean;
releaseNotes: string;
manualUpdateOnly: boolean;
isDownloadingUpdate: boolean;
downloadProgress: number | null;
@@ -32,6 +33,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
const state = reactive<DialogState>({
open: true,
portableMode: false,
releaseNotes: "",
manualUpdateOnly: false,
isDownloadingUpdate: false,
downloadProgress: 0,
@@ -76,7 +78,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
manual_update_only: state.manualUpdateOnly,
release_name: "DBX v0.5.61",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.61",
release_notes: "",
release_notes: state.releaseNotes,
},
updateCheckMessage: "",
checkingUpdates: false,
@@ -110,7 +112,7 @@ function buttonWithText(text: string): HTMLButtonElement | undefined {
}
function downloadButton(): HTMLButtonElement | undefined {
return buttonWithText("Download in Background");
return buttonWithText("Retry Download");
}
function cancelDownloadButton(): HTMLButtonElement | undefined {
@@ -118,7 +120,7 @@ function cancelDownloadButton(): HTMLButtonElement | undefined {
}
function installDownloadedButton(): HTMLButtonElement | undefined {
return buttonWithText("Exit & Update");
return buttonWithText("Restart & Update");
}
async function pressEscape() {
@@ -338,10 +340,10 @@ describe("UpdateDialog ignore version", () => {
expect(ignoreVersion).toHaveBeenCalledOnce();
});
it("hides the ignore button once an update has been downloaded", async () => {
it("allows ignoring a downloaded update", async () => {
await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 });
expect(buttonWithText("Ignore this version")).toBeUndefined();
expect(buttonWithText("Ignore this version")).toBeDefined();
});
it("disables the ignore button while the setting is being persisted", async () => {
@@ -350,3 +352,14 @@ describe("UpdateDialog ignore version", () => {
expect(buttonWithText("Ignore this version")?.disabled).toBe(true);
});
});
describe("UpdateDialog release notes safety", () => {
it("renders remote HTML as text and never creates unsafe links or image requests", async () => {
await mountDialog(0, { releaseNotes: '<img src="https://example.com/tracker" onerror="alert(1)"><script>alert(1)</script> [bad](javascript:alert) ![remote](https://example.com/image) [safe](https://example.com/release)' });
await vi.waitFor(() => {
expect(document.body.querySelector('a[href="https://example.com/release"]')).not.toBeNull();
});
expect(document.body.querySelector("script, img")).toBeNull();
expect(Array.from(document.body.querySelectorAll("a")).every((anchor) => anchor.href.startsWith("https://"))).toBe(true);
});
});
@@ -19,6 +19,7 @@ const props = defineProps<{
downloadProgress: number | null;
updateDownloaded: boolean;
isInstallingUpdate: boolean;
isPreparingUpdate?: boolean;
updateReady: boolean;
isIgnoringUpdate: boolean;
activeTaskCount: number;
@@ -45,7 +46,7 @@ const renderedNotes = ref("");
// download survives the dialog closing, so closing it never cancels the download.
const isCloseBlocked = computed(() => props.isInstallingUpdate);
const blocksImplicitDismiss = computed(() => props.isInstallingUpdate);
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.updateDownloaded && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
function handleCancel() {
handleOpenChange(false);
@@ -66,7 +67,7 @@ function handleReleaseNotesClick(event: MouseEvent) {
if (!anchor) return;
event.preventDefault();
const url = anchor.getAttribute("href");
if (!url) return;
if (!url || !/^https?:\/\//i.test(url)) return;
if (isTauriRuntime()) {
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
} else {
@@ -82,7 +83,19 @@ watch(
return;
}
const { Marked } = await import("marked");
const marked = new Marked({ breaks: true, gfm: true });
const escapeHtml = (value: string) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
const marked = new Marked({
breaks: true,
gfm: true,
renderer: {
html: ({ text }) => escapeHtml(text),
link({ href, tokens }) {
const text = this.parser.parseInline(tokens);
return /^https?:\/\//i.test(href) ? `<a href="${escapeHtml(href)}" rel="noopener noreferrer">${text}</a>` : text;
},
image: ({ text }) => escapeHtml(text),
},
});
renderedNotes.value = marked.parse(notes) as string;
},
{ immediate: true },
@@ -117,7 +130,7 @@ watch(
})
}}
</p>
<p v-else class="text-muted-foreground">
<p v-else-if="!checkingUpdates" class="text-muted-foreground">
{{ updateCheckMessage || t("updates.upToDate", { version: updateInfo?.current_version || "" }) }}
</p>
<div
@@ -142,10 +155,13 @@ watch(
<span>{{ t("updates.activeTasksBlockUpdate", { count: activeTaskCount }) }}</span>
</div>
</div>
<p v-if="checkingUpdates" class="text-sm text-muted-foreground">{{ t("updates.checking") }}</p>
<p v-if="updateDownloaded && !isInstallingUpdate" class="text-sm">{{ t("updates.downloadedReady", { version: updateInfo?.latest_version }) }}</p>
<p v-if="updateCheckFailed && updateInfo?.update_available" role="alert" class="text-sm text-destructive">{{ updateCheckMessage }}</p>
<DialogFooter class="relative min-w-0 sm:justify-end">
<div v-if="updateCheckFailed && !isDownloadingUpdate && !updateReady" class="flex items-center gap-1.5 self-start sm:absolute sm:left-4 sm:top-1/2 sm:-translate-y-1/2">
<div v-if="!updateReady && !isInstallingUpdate && !updateDownloaded && (updateInfo?.update_available || updateCheckFailed)" class="flex items-center gap-1.5 self-start sm:absolute sm:left-4 sm:top-1/2 sm:-translate-y-1/2">
<span class="text-xs text-muted-foreground">{{ t("updates.source") }}</span>
<Select :model-value="updateDownloadSource" :disabled="checkingUpdates" @update:model-value="(value) => value && emit('change-download-source', value as UpdateDownloadSource)">
<Select :model-value="updateDownloadSource" :disabled="checkingUpdates || isIgnoringUpdate" @update:model-value="(value) => value && emit('change-download-source', value as UpdateDownloadSource)">
<SelectTrigger class="h-8 w-[150px] text-xs">
<SelectValue />
</SelectTrigger>
@@ -165,10 +181,10 @@ watch(
<Button variant="outline" class="shrink-0" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
</div>
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('restart')">{{ t("updates.restart") }}</Button>
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0 || isIgnoringUpdate" @click="emit('restart')">{{ t("updates.restart") }}</Button>
<Button v-else-if="isInstallingUpdate" class="shrink-0" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.installing") }}
{{ t(isPreparingUpdate ? "updates.preparing" : "updates.installing") }}
</Button>
<template v-else-if="isDownloadingUpdate">
<Button variant="ghost" class="shrink-0" @click="emit('cancel-download')">{{ t("updates.cancelDownload") }}</Button>
@@ -177,8 +193,8 @@ watch(
{{ 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" @click="emit('download-in-background')">{{ t("updates.downloadInBackground") }}</Button>
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0 || isIgnoringUpdate" @click="emit('install-downloaded')">{{ t("updates.restartAndUpdate") }}</Button>
<Button v-else-if="!checkingUpdates" class="shrink-0" :disabled="isIgnoringUpdate" @click="emit('download-in-background')">{{ t("updates.retryDownload") }}</Button>
</template>
</template>
<template v-else>
@@ -1,89 +1,14 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import { createApp, h } from "vue";
import { describe, expect, it } from "vitest";
import ToolbarUpdateIcon from "../ToolbarUpdateIcon.vue";
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("ToolbarUpdateIcon", () => {
it("uses cloud download while idle and a cloud outline scan while checking", async () => {
const state = reactive({ loading: false });
describe("silent toolbar update icon", () => {
it.each([{ loading: false }, { loading: true }, { downloading: true, progress: 0.4 }, { downloading: true, progress: null }])("keeps the ordinary icon for %o", (props) => {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () => h(ToolbarUpdateIcon, { loading: state.loading }),
}),
);
mountedApps.push(app);
const app = createApp({ render: () => h(ToolbarUpdateIcon, props) });
app.mount(container);
await nextTick();
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
state.loading = true;
await nextTick();
expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
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%)");
expect(container.querySelector("[data-toolbar-update-progress], [data-toolbar-update-scan]")).toBeNull();
app.unmount();
});
});