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

+255 -323
View File
@@ -1,59 +1,32 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, ref, type App } from "vue";
import { createApp, defineComponent, h, reactive, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { useAppUpdater } from "@/composables/useAppUpdater";
const apiMock = vi.hoisted(() => ({
cancelUpdateDownload: vi.fn<() => Promise<void>>(),
downloadUpdate: vi.fn<() => Promise<void>>(),
installDownloadedUpdate: vi.fn<() => Promise<void>>(),
const mocks = vi.hoisted(() => ({
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
cancelUpdateDownload: vi.fn(),
installDownloadedUpdate: vi.fn(),
getDownloadedUpdate: vi.fn(),
discardDownloadedUpdate: vi.fn(),
getAppVersion: vi.fn(),
listen: vi.fn(),
relaunch: vi.fn(),
persist: vi.fn(),
toast: vi.fn(),
}));
const listenMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/lib/backend/tauriRuntime", () => ({
isTauriRuntime: () => true,
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: toastMock }),
}));
const settingsStoreMock = vi.hoisted(() => ({
editorSettings: { updateDownloadSource: "official", ignoredUpdateVersion: "" },
updateEditorSettingsAndPersist: vi.fn<() => Promise<void>>(),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => settingsStoreMock,
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: listenMock,
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: Deferred<T>["resolve"];
let reject!: Deferred<T>["reject"];
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
let app: App | undefined;
let container: HTMLDivElement | undefined;
function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
container = document.createElement("div");
document.body.append(container);
vi.mock("@/lib/backend/api", () => mocks);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: mocks.relaunch }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
const settings = reactive({ updateDownloadSource: "official", ignoredUpdateVersion: "", updateNotificationsEnabled: true });
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: settings, updateEditorSettingsAndPersist: mocks.persist }) }));
const info = { current_version: "1.0.0", latest_version: "1.1.0", update_available: true, portable_mode: false, manual_update_only: false, release_name: "v1.1.0", release_url: "https://example.com", release_notes: "Changes" };
const cache = { cache_id: "cached", version: "1.1.0", portable_mode: false, release_url: "https://example.com", release_notes: "Changes", downloaded_at: 1 };
let app: App;
function mount(options: Parameters<typeof useAppUpdater>[0] = {}) {
let updater!: ReturnType<typeof useAppUpdater>;
app = createApp(
defineComponent({
@@ -64,299 +37,258 @@ function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
}),
);
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: "",
};
app.mount(document.createElement("div"));
return updater;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flush() {
for (let i = 0; i < 12; i++) await Promise.resolve();
await nextTick();
}
beforeEach(() => {
vi.clearAllMocks();
settingsStoreMock.editorSettings.ignoredUpdateVersion = "";
settingsStoreMock.updateEditorSettingsAndPersist.mockResolvedValue();
listenMock.mockResolvedValue(vi.fn());
apiMock.installDownloadedUpdate.mockResolvedValue();
vi.resetAllMocks();
settings.updateDownloadSource = "official";
settings.ignoredUpdateVersion = "";
settings.updateNotificationsEnabled = true;
mocks.checkForUpdates.mockResolvedValue(info);
mocks.downloadUpdate.mockResolvedValue(cache);
mocks.getDownloadedUpdate.mockResolvedValue(null);
mocks.getAppVersion.mockResolvedValue("1.0.0");
mocks.listen.mockResolvedValue(vi.fn());
mocks.persist.mockImplementation(async (values) => Object.assign(settings, values));
});
afterEach(() => {
app?.unmount();
container?.remove();
app = undefined;
container = undefined;
vi.useRealTimers();
});
describe("useAppUpdater download attempts", () => {
it("waits for cancellation and ignores stale completion from the previous attempt", async () => {
const firstDownload = deferred<void>();
const secondDownload = deferred<void>();
const cancellation = deferred<void>();
apiMock.downloadUpdate.mockImplementationOnce(() => firstDownload.promise).mockImplementationOnce(() => secondDownload.promise);
apiMock.cancelUpdateDownload.mockReturnValueOnce(cancellation.promise);
const updater = mountUpdater();
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.downloadUpdateInBackground();
await Promise.resolve();
expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1);
expect(updater.isDownloadingUpdate.value).toBe(true);
cancellation.resolve();
await cancelAttempt;
await vi.waitFor(() => expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(2));
firstDownload.reject(new Error("Download canceled by user."));
await firstAttempt;
expect(updater.isDownloadingUpdate.value).toBe(true);
// 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();
describe("silent update lifecycle", () => {
it("automatically downloads without surfacing or installing, even while idle", async () => {
const updater = mount();
await updater.checkUpdates({ silent: true });
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes");
expect(updater.phase.value).toBe("ready");
expect(updater.hasUpdateAvailable.value).toBe(true);
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) }));
expect(mocks.toast).not.toHaveBeenCalled();
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
expect(mocks.relaunch).not.toHaveBeenCalled();
});
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();
it("shows no badge until a download finishes, and closing does not cancel", async () => {
const pending = deferred<typeof cache>();
mocks.downloadUpdate.mockReturnValue(pending.promise);
const updater = mount();
const checking = updater.checkUpdates();
await flush();
expect(updater.hasUpdateAvailable.value).toBe(false);
updater.showUpdateDialog.value = false;
pending.resolve(cache);
await checking;
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);
expect(mocks.cancelUpdateDownload).not.toHaveBeenCalled();
});
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);
it("restores offline and one click saves before installing and restarting without downloading", async () => {
mocks.getDownloadedUpdate.mockResolvedValue(cache);
const release = vi.fn();
const prepare = vi.fn(async () => release);
const updater = mount({ prepareForUpdate: prepare });
await updater.initialize();
await updater.installDownloadedUpdate();
expect(mocks.checkForUpdates).not.toHaveBeenCalled();
expect(mocks.downloadUpdate).not.toHaveBeenCalled();
expect(mocks.installDownloadedUpdate).toHaveBeenCalledWith("cached", "1.1.0");
expect(prepare.mock.invocationCallOrder[0]).toBeLessThan(mocks.installDownloadedUpdate.mock.invocationCallOrder[0]);
expect(mocks.installDownloadedUpdate.mock.invocationCallOrder[0]).toBeLessThan(mocks.relaunch.mock.invocationCallOrder[0]);
expect(release).not.toHaveBeenCalled();
});
});
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("preparation failure retains the package and repeated clicks cannot bypass preparation", async () => {
mocks.getDownloadedUpdate.mockResolvedValue(cache);
const pending = deferred<() => void>();
const updater = mount({ prepareForUpdate: () => pending.promise });
await updater.checkUpdates({ silent: true });
const install = updater.installDownloadedUpdate();
await updater.installDownloadedUpdate();
pending.reject(new Error("unsaved grid"));
await install;
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
expect(updater.phase.value).toBe("ready");
expect(updater.updateCheckMessage.value).toContain("unsaved grid");
});
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();
it("blocks installation for active work but never blocks automatic download", async () => {
const updater = mount({ getActiveTaskCount: () => 2 });
await updater.checkUpdates({ silent: true });
expect(updater.updateDownloaded.value).toBe(true);
await updater.installDownloadedUpdate();
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
});
it("retries only restart after relaunch fails", async () => {
mocks.relaunch.mockRejectedValueOnce(new Error("restart failed"));
const updater = mount();
await updater.checkUpdates({ silent: true });
await updater.installDownloadedUpdate();
await updater.restartApp();
expect(mocks.installDownloadedUpdate).toHaveBeenCalledOnce();
expect(mocks.relaunch).toHaveBeenCalledTimes(2);
});
it("persists ignore before deleting a ready cache", async () => {
const updater = mount();
await updater.checkUpdates({ silent: true });
await updater.ignoreCurrentVersion();
expect(mocks.persist).toHaveBeenCalledWith({ ignoredUpdateVersion: "1.1.0" });
expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith("cached");
expect(updater.updateDownloaded.value).toBe(false);
});
it("keeps ready cache if ignoring cannot persist", async () => {
mocks.persist.mockRejectedValue(new Error("disk full"));
const updater = mount();
await updater.checkUpdates({ silent: true });
await updater.ignoreCurrentVersion();
expect(mocks.discardDownloadedUpdate).not.toHaveBeenCalled();
expect(updater.updateDownloaded.value).toBe(true);
});
it("hides ready badge when notifications disabled but preserves manual installation", async () => {
mocks.getDownloadedUpdate.mockResolvedValue(cache);
const updater = mount();
await updater.initialize();
settings.updateNotificationsEnabled = false;
await flush();
expect(updater.hasUpdateAvailable.value).toBe(false);
expect(updater.updateDownloaded.value).toBe(true);
expect(mocks.discardDownloadedUpdate).not.toHaveBeenCalled();
});
it("ignores progress belonging to another version or download attempt", async () => {
const pending = deferred<typeof cache>();
mocks.downloadUpdate.mockReturnValue(pending.promise);
const updater = mount();
const checking = updater.checkUpdates({ silent: true });
await vi.waitFor(() => expect(mocks.listen).toHaveBeenCalled());
const callback = mocks.listen.mock.calls[0][1];
const attempt = mocks.downloadUpdate.mock.calls[0][2];
callback({ payload: { downloaded: 90, total: 100, attempt_id: "old", version: "1.1.0" } });
expect(updater.downloadProgress.value).toBeNull();
callback({ payload: { downloaded: 30, total: 100, attempt_id: attempt, version: "1.1.0" } });
expect(updater.downloadProgress.value).toBe(30);
pending.resolve(cache);
await checking;
});
it("silently backs off after failed downloads at 1, 5 and 15 minutes", async () => {
vi.useFakeTimers();
mocks.downloadUpdate.mockRejectedValue(new Error("network down"));
const updater = mount();
await updater.initialize();
await vi.advanceTimersByTimeAsync(0);
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1);
for (const [index, delay] of [60_000, 300_000, 900_000].entries()) {
await vi.advanceTimersByTimeAsync(delay);
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(index + 2);
}
await vi.advanceTimersByTimeAsync(60_000);
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(4);
expect(updater.showUpdateDialog.value).toBe(false);
await updater.checkUpdates();
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
expect(updater.showUpdateDialog.value).toBe(true);
expect(mocks.toast).not.toHaveBeenCalled();
});
it("changing source keeps an already prepared package", async () => {
const updater = mount();
await updater.checkUpdates({ silent: true });
await updater.changeUpdateDownloadSource("cnb");
expect(mocks.downloadUpdate).toHaveBeenCalledOnce();
expect(mocks.checkForUpdates).toHaveBeenCalledOnce();
});
it("reconciles a download that commits just before cancellation without installing it", async () => {
const pending = deferred<typeof cache>();
mocks.downloadUpdate.mockReturnValue(pending.promise);
mocks.getDownloadedUpdate.mockResolvedValue(cache);
mocks.cancelUpdateDownload.mockImplementation(async () => {
pending.resolve(cache);
});
const updater = mount();
const check = updater.checkUpdates({ silent: true });
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalled());
await updater.cancelDownload();
await check;
expect(updater.updateDownloaded.value).toBe(true);
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
});
it("does not resurrect a logically ignored package if removing its files fails", async () => {
mocks.discardDownloadedUpdate.mockRejectedValue(new Error("file busy"));
const updater = mount();
await updater.checkUpdates({ silent: true });
await updater.ignoreCurrentVersion();
expect(settings.ignoredUpdateVersion).toBe("1.1.0");
expect(updater.updateDownloaded.value).toBe(false);
expect(updater.hasUpdateAvailable.value).toBe(false);
expect(updater.updateCheckMessage.value).toContain("file busy");
});
it("clears a corrupt installation cache so download can be retried", async () => {
mocks.installDownloadedUpdate.mockRejectedValue(new Error("signature invalid"));
const updater = mount();
await updater.checkUpdates({ silent: true });
await updater.installDownloadedUpdate();
expect(updater.phase.value).toBe("idle");
expect(updater.updateDownloaded.value).toBe(false);
expect(mocks.relaunch).not.toHaveBeenCalled();
});
it("resurfaces the dialog without re-checking once the update is installed and awaiting restart", async () => {
apiMock.downloadUpdate.mockResolvedValueOnce();
const updater = mountUpdater();
it("holds the preparation barrier through install and releases it only on failure", async () => {
const release = vi.fn();
mocks.installDownloadedUpdate.mockRejectedValue(new Error("installer failed"));
mocks.getDownloadedUpdate.mockResolvedValue(cache);
const updater = mount({ prepareForUpdate: async () => release });
await updater.checkUpdates({ silent: true });
await updater.installDownloadedUpdate();
expect(release).toHaveBeenCalledOnce();
expect(updater.updateDownloaded.value).toBe(true);
});
it("checks fresh metadata immediately once when the downloaded version changes", async () => {
mocks.downloadUpdate.mockRejectedValueOnce(new Error("Update version changed; check for updates again."));
const updater = mount();
await updater.checkUpdates({ silent: true });
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(2));
expect(mocks.checkForUpdates).toHaveBeenCalledTimes(2);
expect(updater.updateDownloaded.value).toBe(true);
});
it("cannot install or switch sources while an ignore setting is being persisted", async () => {
const persist = deferred<void>();
mocks.persist.mockReturnValue(persist.promise);
const updater = mount();
await updater.checkUpdates({ silent: true });
const ignoring = updater.ignoreCurrentVersion();
await updater.installDownloadedUpdate();
await updater.changeUpdateDownloadSource("cnb");
await updater.checkUpdates({ silent: true });
await updater.downloadUpdateInBackground();
expect(updater.updateReady.value).toBe(true);
await updater.checkUpdates();
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
expect(updater.showUpdateDialog.value).toBe(true);
expect(mocks.checkForUpdates).toHaveBeenCalledTimes(1);
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1);
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
expect(mocks.persist).toHaveBeenCalledTimes(1);
persist.resolve();
await ignoring;
expect(updater.updateDownloaded.value).toBe(false);
});
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 failure state handling", () => {
interface UpdateInfo {
current_version: string;
latest_version: string;
update_available: boolean;
release_name: string;
release_url: string;
release_notes: string;
}
it("keeps the failed state visible while a retry is in flight and clears it on success", async () => {
apiMock.checkForUpdates.mockRejectedValueOnce(new Error("boom"));
const updater = mountUpdater();
await updater.checkUpdates();
expect(updater.updateCheckFailed.value).toBe(true);
expect(updater.updateCheckMessage.value).not.toBe("");
const retry = deferred<UpdateInfo>();
apiMock.checkForUpdates.mockReturnValueOnce(retry.promise);
const pending = updater.checkUpdates();
await vi.waitFor(() => expect(updater.checkingUpdates.value).toBe(true));
// Mid-retry: the source switcher stays mounted (updateCheckFailed) and
// the dialog must not fall through to "up to date" with an empty version.
expect(updater.updateCheckFailed.value).toBe(true);
expect(updater.updateCheckMessage.value).not.toBe("");
retry.resolve({
current_version: "0.5.69",
latest_version: "0.5.69",
update_available: false,
release_name: "",
release_url: "",
release_notes: "",
});
await pending;
expect(updater.updateCheckFailed.value).toBe(false);
expect(updater.updateCheckMessage.value).toContain("0.5.69");
});
it("keeps the last update info when a silent background check fails", async () => {
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();
await updater.checkUpdates({ silent: true });
expect(updater.updateInfo.value?.update_available).toBe(true);
apiMock.checkForUpdates.mockRejectedValueOnce(new Error("network down"));
await updater.checkUpdates({ silent: true });
expect(updater.updateInfo.value?.update_available).toBe(true);
expect(updater.updateCheckFailed.value).toBe(false);
expect(updater.showUpdateDialog.value).toBe(false);
});
});
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();
expect(settingsStoreMock.updateEditorSettingsAndPersist).toHaveBeenCalledWith({ ignoredUpdateVersion: "0.5.70" });
expect(updater.showUpdateDialog.value).toBe(false);
expect(toastMock).toHaveBeenCalledWith("Version v0.5.70 ignored. You'll be reminded when the next version releases.", 5000);
});
it("keeps the dialog open and allows retry when persistence fails", async () => {
settingsStoreMock.updateEditorSettingsAndPersist.mockRejectedValueOnce(new Error("storage unavailable")).mockResolvedValueOnce();
const updater = mountUpdater();
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
expect(updater.showUpdateDialog.value).toBe(true);
expect(updater.isIgnoringUpdate.value).toBe(false);
expect(toastMock).toHaveBeenLastCalledWith("Failed to save the ignored version: storage unavailable", 5000);
await updater.ignoreCurrentVersion();
expect(settingsStoreMock.updateEditorSettingsAndPersist).toHaveBeenCalledTimes(2);
expect(updater.showUpdateDialog.value).toBe(false);
it("resumes after notifications are reenabled while cancellation is pending", async () => {
const pendingDownload = deferred<typeof cache>();
const pendingCancel = deferred<void>();
mocks.downloadUpdate.mockReturnValueOnce(pendingDownload.promise);
mocks.cancelUpdateDownload.mockReturnValue(pendingCancel.promise);
const updater = mount();
await updater.initialize();
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1));
settings.updateNotificationsEnabled = false;
await nextTick();
settings.updateNotificationsEnabled = true;
await nextTick();
pendingDownload.reject(new Error("cancelled"));
pendingCancel.resolve();
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(2));
expect(updater.updateDownloaded.value).toBe(true);
});
});
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key),
}),
+283 -209
View File
@@ -1,17 +1,16 @@
import { computed, ref } from "vue";
import { computed, ref, watch, onScopeDispose } from "vue";
import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { useSettingsStore } from "@/stores/settingsStore";
import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore";
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
import { currentLocale } from "@/i18n";
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
import { installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
interface UseAppUpdaterOptions {
getActiveTaskCount?: () => number;
prepareForUpdate?: () => Promise<() => void>;
}
export function shouldOpenUpdateDialog(options: { silent?: boolean }) {
@@ -110,252 +109,324 @@ export async function resolveUpdaterProxy(): Promise<string | undefined> {
export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const checkingUpdates = ref(false);
type Phase = "idle" | "checking" | "downloading" | "ready" | "preparing" | "installing" | "restart";
const phase = ref<Phase>("idle");
const downloaded = ref<api.DownloadedUpdate | null>(null);
const updateInfo = ref<api.UpdateInfo | null>(null);
const updateCheckMessage = ref("");
const updateCheckFailed = ref(false);
const showUpdateDialog = ref(false);
const isDownloadingUpdate = ref(false);
// 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);
const downloadProgress = ref<number | null>(null);
const isIgnoringUpdate = ref(false);
const checkingUpdates = computed(() => phase.value === "checking");
const isDownloadingUpdate = computed(() => phase.value === "downloading");
const updateDownloaded = computed(() => downloaded.value !== null);
const isPreparingUpdate = computed(() => phase.value === "preparing");
const isInstallingUpdate = computed(() => phase.value === "installing" || isPreparingUpdate.value);
const updateReady = computed(() => phase.value === "restart");
const activeTaskCount = computed(() => Math.max(0, Math.trunc(options.getActiveTaskCount?.() ?? 0)));
const hasUpdateAvailable = computed(() => updateInfo.value?.update_available === true && !isUpdateIgnored(updateInfo.value, settingsStore.editorSettings.ignoredUpdateVersion));
const notificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled !== false);
const hasUpdateAvailable = computed(
() => notificationsEnabled.value && (updateDownloaded.value || updateReady.value || (updateInfo.value?.update_available === true && (!isTauriRuntime() || updateInfo.value.manual_update_only))) && !isUpdateIgnored(updateInfo.value, settingsStore.editorSettings.ignoredUpdateVersion),
);
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
let activeDownloadAttempt = 0;
let pendingCancellation: Promise<void> | undefined;
let generation = 0;
let activeDownload: Promise<void> | undefined;
let cancellation: Promise<void> | undefined;
let cancelOperation: Promise<void> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let hourlyTimer: ReturnType<typeof setInterval> | undefined;
let retries = 0;
let initialized = false;
let disposed = false;
let versionRecheckPending = false;
let updatePreparationRelease: (() => void) | undefined;
function clearRetry() {
clearTimeout(retryTimer);
retryTimer = undefined;
}
function fail(error: unknown) {
updateCheckFailed.value = true;
updateCheckMessage.value = formatUpdateError(error instanceof Error ? error.message : String(error));
}
function clearError() {
updateCheckFailed.value = false;
updateCheckMessage.value = "";
}
function scheduleRetry() {
clearRetry();
if (!initialized || disposed || !notificationsEnabled.value || retries >= 3) return;
const delay = [60_000, 300_000, 900_000][retries++];
retryTimer = setTimeout(() => {
retryTimer = undefined;
void checkUpdates({ silent: true });
}, delay);
}
function setDownloaded(cache: api.DownloadedUpdate) {
downloaded.value = cache;
updateInfo.value = {
current_version: updateInfo.value?.current_version ?? "",
latest_version: cache.version,
update_available: true,
portable_mode: cache.portable_mode,
manual_update_only: false,
release_name: tagVersion(cache.version),
release_url: cache.release_url,
release_notes: cache.release_notes,
};
phase.value = "ready";
downloadProgress.value = 100;
retries = 0;
clearRetry();
clearError();
}
function openUrl(url: string) {
if (isTauriRuntime()) {
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
} else {
window.open(url, "_blank");
}
if (isTauriRuntime()) void import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
else window.open(url, "_blank", "noopener,noreferrer");
}
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;
// Keep the previous message/failed state visible while the check is in
// flight: pre-clearing would flip the dialog to "up to date" with an empty
// version and unmount the source switcher until the request settles.
try {
const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource));
updateInfo.value = info;
updateCheckMessage.value = "";
updateCheckFailed.value = false;
if (info.update_available) {
if (shouldOpenUpdateDialog({ silent: options.silent })) {
showUpdateDialog.value = true;
}
} else if (!options.silent) {
updateCheckMessage.value = t("updates.upToDate", { version: info.current_version });
showUpdateDialog.value = true;
}
} catch (e: any) {
if (!options.silent) {
// Drop the stale update info only on a surfaced failure; silent
// background checks keep the last known state so the toolbar
// indicator and an in-flight download's portable-mode flag survive
// a single failed poll.
updateInfo.value = null;
updateCheckMessage.value = formatUpdateError(String(e));
updateCheckFailed.value = true;
showUpdateDialog.value = true;
}
} finally {
checkingUpdates.value = false;
}
function openLatestRelease() {
openUrl(resolveUpdateReleaseUrl(updateInfo.value, settingsStore.editorSettings.updateDownloadSource, latestReleaseUrl));
}
function formatUpdateError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("canceled") || lower.includes("cancelled")) {
return t("updates.downloadCanceled");
}
if (lower.includes("403") || lower.includes("rate limit")) {
return t("updates.rateLimited");
}
if (lower.includes("stalled") || lower.includes("timeout") || lower.includes("all available mirrors") || lower.includes("error sending request") || lower.includes("failed to download")) {
return t("updates.networkOrMirrorFailed");
}
if (lower.includes("cancel")) return t("updates.downloadCanceled");
if (lower.includes("403") || lower.includes("rate limit")) return t("updates.rateLimited");
if (["stalled", "timeout", "all available mirrors", "error sending request", "failed to download"].some((part) => lower.includes(part))) return t("updates.networkOrMirrorFailed");
return t("updates.downloadFailed", { error: message });
}
function openLatestRelease() {
const url = resolveUpdateReleaseUrl(updateInfo.value, settingsStore.editorSettings.updateDownloadSource, latestReleaseUrl);
openUrl(url);
async function checkUpdates(checkOptions: { silent?: boolean } = {}) {
if (disposed || isIgnoringUpdate.value) return;
if (!checkOptions.silent) showUpdateDialog.value = true;
if (phase.value !== "idle" || downloaded.value || (checkOptions.silent && !notificationsEnabled.value)) return;
clearRetry();
const token = ++generation;
phase.value = "checking";
clearError();
try {
const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource));
if (token !== generation || disposed) return;
updateInfo.value = info;
phase.value = "idle";
if (!info.update_available) updateCheckMessage.value = t("updates.upToDate", { version: info.current_version });
if (canDownloadAndInstallUpdate(info, isTauriRuntime()) && !isUpdateIgnored(info, settingsStore.editorSettings.ignoredUpdateVersion)) {
await downloadUpdateInBackground();
}
} catch (error) {
if (token !== generation || disposed) return;
phase.value = "idle";
fail(error);
scheduleRetry();
}
}
async function downloadUpdateInBackground() {
if (disposed || isIgnoringUpdate.value || phase.value !== "idle" || downloaded.value || !canDownloadAndInstallUpdate(updateInfo.value, isTauriRuntime())) return;
const version = updateInfo.value!.latest_version;
const token = ++generation;
const attemptId = crypto.randomUUID();
phase.value = "downloading";
downloadProgress.value = null;
clearError();
clearRetry();
const run = async () => {
let unlisten: (() => void) | undefined;
try {
await cancellation;
if (token !== generation || disposed) return;
const { listen } = await import("@tauri-apps/api/event");
unlisten = await listen<UpdateDownloadProgress>("update-download-progress", ({ payload }) => {
if (token !== generation || payload.attempt_id !== attemptId || payload.version !== version) return;
downloadProgress.value = payload.total && payload.total > 0 ? Math.min(100, Math.round((payload.downloaded / payload.total) * 100)) : null;
});
if (token !== generation || disposed) return;
const cache = await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), version, attemptId, updateInfo.value?.release_notes);
if (token !== generation || disposed) return;
if (cache.version !== version) throw new Error("Downloaded update version changed; check for updates again.");
setDownloaded(cache);
} catch (error) {
if (token !== generation || disposed) return;
phase.value = "idle";
fail(error);
if ((String(error).includes("VERSION_CHANGED") || String(error).toLowerCase().includes("version changed")) && !versionRecheckPending) {
versionRecheckPending = true;
setTimeout(() => {
void checkUpdates({ silent: true }).finally(() => {
versionRecheckPending = false;
});
});
} else scheduleRetry();
} finally {
unlisten?.();
}
};
activeDownload = run();
await activeDownload;
activeDownload = undefined;
}
function cancelDownload(): Promise<void> {
if (cancelOperation) return cancelOperation;
cancelOperation = cancelDownloadImpl().finally(() => {
cancelOperation = undefined;
});
return cancelOperation;
}
async function cancelDownloadImpl() {
clearRetry();
generation++;
const running = activeDownload;
if (phase.value === "checking") phase.value = "idle";
if (!running) return;
const pending = api.cancelUpdateDownload();
cancellation = pending;
try {
await pending;
await running;
if (!disposed) {
const cache = await api.getDownloadedUpdate();
if (cache && !isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion)) setDownloaded(cache);
}
} finally {
if (cancellation === pending) cancellation = undefined;
if (phase.value === "downloading") phase.value = "idle";
}
}
async function changeUpdateDownloadSource(source: SettingsUpdateDownloadSource) {
await settingsStore.updateEditorSettingsAndPersist({ updateDownloadSource: source });
await checkUpdates();
if (isIgnoringUpdate.value || isInstallingUpdate.value) return;
try {
await settingsStore.updateEditorSettingsAndPersist({ updateDownloadSource: source });
if (downloaded.value || updateReady.value || isInstallingUpdate.value) return;
await cancelDownload();
retries = 0;
await checkUpdates();
} catch (error) {
fail(error);
}
}
async function ignoreCurrentVersion() {
const latest = updateInfo.value?.latest_version;
if (!latest || isIgnoringUpdate.value) return;
const version = updateInfo.value?.latest_version;
if (!version || isIgnoringUpdate.value || isInstallingUpdate.value || updateReady.value) return;
isIgnoringUpdate.value = true;
try {
await settingsStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: latest });
await cancelDownload();
await settingsStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: version });
const cache = downloaded.value;
downloaded.value = null;
phase.value = "idle";
if (cache) await api.discardDownloadedUpdate(cache.cache_id);
showUpdateDialog.value = false;
toast(t("updates.versionIgnored", { version: tagVersion(latest) }), 5000);
} catch (error) {
toast(t("updates.ignoreVersionFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
fail(error);
} finally {
isIgnoringUpdate.value = false;
}
}
function blockUpdateForActiveTasks(): boolean {
if (!shouldBlockAppUpdate(activeTaskCount.value)) return false;
toast(t("updates.activeTasksBlockUpdate", { count: activeTaskCount.value }), 5000);
return true;
}
async function downloadUpdateInBackground() {
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || updateDownloaded.value || updateReady.value) return;
if (!canDownloadAndInstallUpdate(updateInfo.value, true)) {
openLatestRelease();
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;
// 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);
async function performInstall(restartOnly: boolean) {
if (!isTauriRuntime() || isInstallingUpdate.value || isIgnoringUpdate.value || (!restartOnly && !downloaded.value)) return;
const cache = downloaded.value;
let release: (() => void) | undefined;
phase.value = "preparing";
clearError();
let installed = restartOnly;
let failed = false;
try {
await pendingCancellation;
if (attempt !== activeDownloadAttempt) return;
const { listen } = await import("@tauri-apps/api/event");
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) : null;
});
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();
release = await options.prepareForUpdate?.();
updatePreparationRelease = release;
if (shouldBlockAppUpdate(activeTaskCount.value)) throw new Error(t("updates.activeTasksBlockUpdate", { count: activeTaskCount.value }));
phase.value = "installing";
if (!restartOnly && cache) {
await api.installDownloadedUpdate(cache.cache_id, cache.version);
installed = true;
downloaded.value = null;
}
} catch (e: any) {
if (attempt !== activeDownloadAttempt) return;
downloadProgress.value = 0;
const rawMsg = e?.message || String(e);
console.error("[DBX update error]", rawMsg);
const lower = rawMsg.toLowerCase();
if (lower.includes("canceled") || lower.includes("cancelled")) {
return;
// Windows installers and the portable helper own process replacement/relaunch.
if (!cache?.portable_mode && !navigator.userAgent.includes("Windows")) {
phase.value = "restart";
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
toast(formatUpdateError(rawMsg), 5000);
} catch (error) {
failed = true;
phase.value = installed ? "restart" : "ready";
updateCheckFailed.value = true;
updateCheckMessage.value = t(installed ? "updates.restartFailed" : "updates.installFailed", { error: error instanceof Error ? error.message : String(error) });
} finally {
unlisten?.();
if (attempt === activeDownloadAttempt) {
isDownloadingUpdate.value = false;
// Successful handoff must keep every window frozen until the process exits.
if (failed) {
release?.();
updatePreparationRelease = undefined;
}
}
}
async function installPendingUpdate() {
isInstallingUpdate.value = true;
try {
const portableMode = updateInfo.value?.portable_mode === true;
await api.installDownloadedUpdate();
updateDownloaded.value = false;
// The portable helper exits and relaunches DBX after the invoke response is delivered.
updateReady.value = !portableMode;
} finally {
isInstallingUpdate.value = false;
}
}
async function installDownloadedUpdate() {
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || !updateDownloaded.value) return;
try {
const installed = await installDownloadedUpdateWhenIdle({
getActiveTaskCount: () => activeTaskCount.value,
install: installPendingUpdate,
});
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);
}
}
async function restartApp() {
if (!isTauriRuntime()) return;
if (blockUpdateForActiveTasks()) return;
try {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (e: any) {
toast(t("updates.restartFailed", { error: e?.message || String(e) }), 5000);
}
}
async function cancelDownload() {
if (isDownloadingUpdate.value) {
activeDownloadAttempt += 1;
isDownloadingUpdate.value = false;
downloadProgress.value = 0;
if (isTauriRuntime()) {
const cancellation = api.cancelUpdateDownload().catch(() => {});
pendingCancellation = cancellation;
if (!installed && cache) {
try {
await cancellation;
} finally {
if (pendingCancellation === cancellation) {
pendingCancellation = undefined;
const restored = await api.getDownloadedUpdate();
if (!restored || restored.cache_id !== cache.cache_id) {
downloaded.value = null;
phase.value = "idle";
}
} catch {
/* Keep the original installation error available for retry. */
}
}
}
}
async function installDownloadedUpdate() {
await performInstall(false);
}
async function restartApp() {
if (updateReady.value) await performInstall(true);
}
async function initialize() {
if (initialized || disposed) return;
initialized = true;
if (isTauriRuntime()) {
phase.value = "checking";
try {
const cache = await api.getDownloadedUpdate();
if (disposed) return;
if (cache) {
const ignored = isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion);
if (ignored) await api.discardDownloadedUpdate(cache.cache_id);
else {
setDownloaded(cache);
updateInfo.value!.current_version = await api.getAppVersion();
}
}
} catch (error) {
fail(error);
} finally {
if (phase.value === "checking") phase.value = "idle";
}
}
if (disposed) return;
hourlyTimer = setInterval(() => {
if (!retryTimer) {
retries = 0;
void checkUpdates({ silent: true });
}
}, 3_600_000);
void checkUpdates({ silent: true });
}
const stopSettingsWatch = watch(notificationsEnabled, (enabled) => {
if (!initialized || disposed) return;
const settle = enabled ? (cancelOperation ?? Promise.resolve()) : cancelDownload();
void settle
.then(() => {
if (notificationsEnabled.value && !disposed) void checkUpdates({ silent: true });
})
.catch(fail);
});
function dispose() {
disposed = true;
clearRetry();
clearInterval(hourlyTimer);
stopSettingsWatch();
updatePreparationRelease?.();
updatePreparationRelease = undefined;
void cancelDownload().catch(() => {});
}
onScopeDispose(dispose);
return {
phase,
checkingUpdates,
updateInfo,
updateCheckMessage,
@@ -365,6 +436,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
downloadProgress,
updateDownloaded,
isInstallingUpdate,
isPreparingUpdate,
updateReady,
isIgnoringUpdate,
activeTaskCount,
@@ -380,5 +452,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
cancelDownload,
installDownloadedUpdate,
restartApp,
initialize,
dispose,
};
}