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

+166 -63
View File
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, type App } from "vue";
import { createApp, defineComponent, h, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { useAppUpdater } from "@/composables/useAppUpdater";
@@ -9,6 +9,7 @@ const apiMock = vi.hoisted(() => ({
cancelUpdateDownload: vi.fn<() => Promise<void>>(),
downloadUpdate: vi.fn<() => Promise<void>>(),
installDownloadedUpdate: vi.fn<() => Promise<void>>(),
checkForUpdates: vi.fn(),
}));
const listenMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => vi.fn());
@@ -50,6 +51,31 @@ function deferred<T>(): Deferred<T> {
let app: App | undefined;
let container: HTMLDivElement | undefined;
function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
container = document.createElement("div");
document.body.append(container);
let updater!: ReturnType<typeof useAppUpdater>;
app = createApp(
defineComponent({
setup() {
updater = useAppUpdater(options);
return () => h("div");
},
}),
);
app.use(i18n);
app.mount(container);
updater.updateInfo.value = {
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
};
return updater;
}
beforeEach(() => {
vi.clearAllMocks();
settingsStoreMock.editorSettings.ignoredUpdateVersion = "";
@@ -73,35 +99,15 @@ describe("useAppUpdater download attempts", () => {
apiMock.downloadUpdate.mockImplementationOnce(() => firstDownload.promise).mockImplementationOnce(() => secondDownload.promise);
apiMock.cancelUpdateDownload.mockReturnValueOnce(cancellation.promise);
let updater!: ReturnType<typeof useAppUpdater>;
container = document.createElement("div");
document.body.append(container);
app = createApp(
defineComponent({
setup() {
updater = useAppUpdater();
return () => h("div");
},
}),
);
app.use(i18n);
app.mount(container);
updater.updateInfo.value = {
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
};
const updater = mountUpdater();
const firstAttempt = updater.downloadAndInstallUpdate();
const firstAttempt = updater.downloadUpdateInBackground();
await vi.waitFor(() => expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1));
const cancelAttempt = updater.cancelDownload();
expect(updater.isDownloadingUpdate.value).toBe(false);
const retryAttempt = updater.downloadAndInstallUpdate();
const retryAttempt = updater.downloadUpdateInBackground();
await Promise.resolve();
expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1);
expect(updater.isDownloadingUpdate.value).toBe(true);
@@ -113,40 +119,157 @@ describe("useAppUpdater download attempts", () => {
firstDownload.reject(new Error("Download canceled by user."));
await firstAttempt;
expect(updater.isDownloadingUpdate.value).toBe(true);
expect(updater.downloadProgress.value).toBe(0);
// The retry is in flight with no progress event yet, so progress is indeterminate.
expect(updater.downloadProgress.value).toBeNull();
secondDownload.resolve();
await retryAttempt;
// No active tasks by default, so the successful download auto-installs and lands on
// "ready to restart" instead of waiting for a manual "Install Now" click.
expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce();
expect(updater.isDownloadingUpdate.value).toBe(false);
expect(updater.downloadProgress.value).toBe(100);
expect(updater.updateDownloaded.value).toBe(false);
expect(updater.updateReady.value).toBe(true);
expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) }));
});
it("auto-installs once the download finishes while idle, without waiting for a manual click", async () => {
apiMock.downloadUpdate.mockResolvedValueOnce();
const updater = mountUpdater();
updater.showUpdateDialog.value = true;
const attempt = updater.downloadUpdateInBackground();
expect(updater.showUpdateDialog.value).toBe(false);
await attempt;
expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce();
expect(updater.updateDownloaded.value).toBe(false);
expect(updater.updateReady.value).toBe(true);
expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) }));
});
it("falls back to a manual Install Now toast when tasks are still active once the download finishes", async () => {
apiMock.downloadUpdate.mockResolvedValueOnce();
// Must be a reactive ref, not a plain closure variable — useAppUpdater's activeTaskCount
// is a computed(), which only invalidates its cache when a tracked reactive source changes.
const activeTaskCount = ref(2);
const updater = mountUpdater({ getActiveTaskCount: () => activeTaskCount.value });
await updater.downloadUpdateInBackground();
expect(apiMock.installDownloadedUpdate).not.toHaveBeenCalled();
expect(updater.updateDownloaded.value).toBe(true);
expect(toastMock).toHaveBeenLastCalledWith("DBX v0.5.70 is ready to install.", 10000, expect.objectContaining({ label: "Install Now", onClick: expect.any(Function) }));
activeTaskCount.value = 0;
const [, , action] = toastMock.mock.lastCall!;
action.onClick();
await vi.waitFor(() => expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) })));
expect(updater.updateReady.value).toBe(true);
});
it("tracks download progress and stays indeterminate when the backend reports no total", async () => {
const download = deferred<void>();
apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
const updater = mountUpdater();
const downloadAttempt = updater.downloadUpdateInBackground();
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledOnce());
// No progress event yet — the size is unknown, so progress stays indeterminate.
expect(updater.downloadProgress.value).toBeNull();
const onProgress = listenMock.mock.calls[0][1] as (event: { payload: { downloaded: number; total: number | null } }) => void;
onProgress({ payload: { downloaded: 25, total: 100 } });
expect(updater.downloadProgress.value).toBe(25);
// Mirrors may stream chunks without a total; progress must not freeze at a stale percentage.
onProgress({ payload: { downloaded: 64, total: null } });
expect(updater.downloadProgress.value).toBeNull();
download.resolve();
await downloadAttempt;
expect(updater.downloadProgress.value).toBe(100);
});
});
describe("useAppUpdater ignore version", () => {
it("persists the ignored latest version and closes the update dialog", async () => {
let updater!: ReturnType<typeof useAppUpdater>;
container = document.createElement("div");
document.body.append(container);
app = createApp(
defineComponent({
setup() {
updater = useAppUpdater();
return () => h("div");
},
}),
);
app.use(i18n);
app.mount(container);
updater.updateInfo.value = {
describe("useAppUpdater reopening the dialog from the toolbar", () => {
it("resurfaces the dialog without re-checking while a download is in progress", async () => {
const download = deferred<void>();
apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
const updater = mountUpdater();
const downloadAttempt = updater.downloadUpdateInBackground();
await vi.waitFor(() => expect(updater.isDownloadingUpdate.value).toBe(true));
await updater.checkUpdates();
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
expect(updater.showUpdateDialog.value).toBe(true);
download.resolve();
await downloadAttempt;
});
it("resurfaces the dialog without re-checking once the update is downloaded and ready to install", async () => {
apiMock.downloadUpdate.mockResolvedValueOnce();
// Busy at download-completion time so the install step stays pending instead of auto-running.
const updater = mountUpdater({ getActiveTaskCount: () => 1 });
await updater.downloadUpdateInBackground();
expect(updater.updateDownloaded.value).toBe(true);
expect(updater.showUpdateDialog.value).toBe(false);
await updater.checkUpdates();
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
expect(updater.showUpdateDialog.value).toBe(true);
});
it("resurfaces the dialog without re-checking once the update is installed and awaiting restart", async () => {
apiMock.downloadUpdate.mockResolvedValueOnce();
const updater = mountUpdater();
await updater.downloadUpdateInBackground();
expect(updater.updateReady.value).toBe(true);
await updater.checkUpdates();
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
expect(updater.showUpdateDialog.value).toBe(true);
});
it("still checks the network for a silent background check even mid-download", async () => {
const download = deferred<void>();
apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
apiMock.checkForUpdates.mockResolvedValueOnce({
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
};
});
const updater = mountUpdater();
const downloadAttempt = updater.downloadUpdateInBackground();
await vi.waitFor(() => expect(updater.isDownloadingUpdate.value).toBe(true));
await updater.checkUpdates({ silent: true });
expect(apiMock.checkForUpdates).toHaveBeenCalledOnce();
download.resolve();
await downloadAttempt;
});
});
describe("useAppUpdater ignore version", () => {
it("persists the ignored latest version and closes the update dialog", async () => {
const updater = mountUpdater();
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
@@ -158,27 +281,7 @@ describe("useAppUpdater ignore version", () => {
it("keeps the dialog open and allows retry when persistence fails", async () => {
settingsStoreMock.updateEditorSettingsAndPersist.mockRejectedValueOnce(new Error("storage unavailable")).mockResolvedValueOnce();
let updater!: ReturnType<typeof useAppUpdater>;
container = document.createElement("div");
document.body.append(container);
app = createApp(
defineComponent({
setup() {
updater = useAppUpdater();
return () => h("div");
},
}),
);
app.use(i18n);
app.mount(container);
updater.updateInfo.value = {
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
};
const updater = mountUpdater();
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
+49 -23
View File
@@ -8,7 +8,7 @@ import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/sto
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
import { currentLocale } from "@/i18n";
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
import { downloadAndInstallUpdateWhenIdle, installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
import { installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
interface UseAppUpdaterOptions {
getActiveTaskCount?: () => number;
@@ -117,7 +117,8 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const updateCheckMessage = ref("");
const showUpdateDialog = ref(false);
const isDownloadingUpdate = ref(false);
const downloadProgress = ref(0);
// null while the download size is unknown (backend reported no total) — the UI shows an indeterminate state.
const downloadProgress = ref<number | null>(0);
const updateDownloaded = ref(false);
const isInstallingUpdate = ref(false);
const updateReady = ref(false);
@@ -138,6 +139,12 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
async function checkUpdates(options: { silent?: boolean } = {}) {
if (checkingUpdates.value) return;
// The toolbar icon reuses this as its click handler. If a download/install is already
// in flight or finished, just resurface the dialog instead of hitting the network again.
if (!options.silent && (isDownloadingUpdate.value || updateDownloaded.value || updateReady.value)) {
showUpdateDialog.value = true;
return;
}
checkingUpdates.value = true;
updateCheckMessage.value = "";
try {
@@ -201,18 +208,22 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
return true;
}
async function downloadAndInstallUpdate() {
async function downloadUpdateInBackground() {
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || updateDownloaded.value || updateReady.value) return;
if (!canDownloadAndInstallUpdate(updateInfo.value, true)) {
openLatestRelease();
return;
}
if (blockUpdateForActiveTasks()) return;
// Starting the download never has to wait on active work — only installing
// (file replacement) is disruptive, so that guard applies at install time instead.
showUpdateDialog.value = false;
const attempt = ++activeDownloadAttempt;
isDownloadingUpdate.value = true;
downloadProgress.value = 0;
// The total is unknown until the first progress event arrives.
downloadProgress.value = null;
let unlisten: (() => void) | undefined;
const latestVersion = updateInfo.value?.latest_version;
toast(t("updates.downloadStarted", { version: latestVersion ? tagVersion(latestVersion) : "" }), 3000);
try {
await pendingCancellation;
if (attempt !== activeDownloadAttempt) return;
@@ -220,23 +231,27 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
unlisten = await listen<UpdateDownloadProgress>("update-download-progress", (event) => {
if (attempt !== activeDownloadAttempt) return;
const total = event.payload.total ?? 0;
downloadProgress.value = total > 0 ? Math.round((event.payload.downloaded / total) * 100) : 0;
downloadProgress.value = total > 0 ? Math.round((event.payload.downloaded / total) * 100) : null;
});
const result = await downloadAndInstallUpdateWhenIdle({
getActiveTaskCount: () => activeTaskCount.value,
download: async () => {
await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion);
if (attempt !== activeDownloadAttempt) throw new Error("Download canceled by user.");
downloadProgress.value = 100;
updateDownloaded.value = true;
isDownloadingUpdate.value = false;
},
install: async () => {
await installPendingUpdate();
},
});
if (result === "blocked" || result === "downloaded") {
blockUpdateForActiveTasks();
await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion);
if (attempt !== activeDownloadAttempt) return;
downloadProgress.value = 100;
updateDownloaded.value = true;
// The download phase is truly finished — flip this now so the install guard (which
// checks isDownloadingUpdate) can evaluate freely instead of treating us as still busy.
isDownloadingUpdate.value = false;
if (shouldBlockAppUpdate(activeTaskCount.value)) {
// Matches the pre-refactor behavior: installing waits for active work to finish, but
// unlike before, only installing waits — the download itself never blocked on it.
toast(t("updates.readyToInstall", { version: latestVersion ? tagVersion(latestVersion) : "" }), 10000, {
label: t("updates.installNow"),
onClick: () => {
void installDownloadedUpdate();
},
});
} else {
// Restores the pre-refactor auto-install-when-idle behavior for the install step.
await installDownloadedUpdate();
}
} catch (e: any) {
if (attempt !== activeDownloadAttempt) return;
@@ -276,7 +291,18 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
getActiveTaskCount: () => activeTaskCount.value,
install: installPendingUpdate,
});
if (!installed) blockUpdateForActiveTasks();
if (!installed) {
blockUpdateForActiveTasks();
return;
}
if (updateReady.value) {
toast(t("updates.installedReadyToRestart"), 10000, {
label: t("updates.restart"),
onClick: () => {
void restartApp();
},
});
}
} catch (e: any) {
toast(t("updates.installFailed", { error: e?.message || String(e) }), 5000);
}
@@ -331,7 +357,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
formatUpdateError,
openLatestRelease,
ignoreCurrentVersion,
downloadAndInstallUpdate,
downloadUpdateInBackground,
cancelDownload,
installDownloadedUpdate,
restartApp,