feat(updater): download and prepare desktop updates in background
This commit is contained in:
77 files changed
+10536
-814
No files matched your search
+255
-323
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+2
-1
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user