diff --git a/Cargo.lock b/Cargo.lock
index d9d285dc99..a2439cffe2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8847,8 +8847,6 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
diff --git a/Cargo.toml b/Cargo.toml
index a8a21f4147..6303e6325f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,11 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
-exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
+exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
[patch.crates-io]
+# Offline signed update restoration and checked Windows installer launch.
+tauri-plugin-updater = { path = "vendor/tauri-plugin-updater" }
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
# upstream rust-ctor#443 until tauri-utils moves to ctor 1.x.
ctor = { path = "vendor/ctor" }
diff --git a/README.zh-CN.md b/README.zh-CN.md
index b413db3472..c08c8e21a2 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -526,7 +526,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
DBX 会收集用户数据吗?
-不会。DBX 不收集任何遥测数据。自动更新功能仅检查 GitHub Releases 获取新版本——你可以在设置中禁用它。
+不会。DBX 不收集任何遥测数据。开启更新通知时,桌面端会通过所选更新源检查新版本并静默下载安装包;下载并校验完成后,更新入口显示提示,只有点击“重启并更新”才会安装。已下载的安装包会保留到下次启动,也可忽略该版本。你可以在设置中关闭自动检查和下载。
diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue
index ecf041c34c..0fd1e52d19 100644
--- a/apps/desktop/src/App.vue
+++ b/apps/desktop/src/App.vue
@@ -1,4 +1,6 @@
diff --git a/apps/desktop/src/components/layout/AppToolbar.vue b/apps/desktop/src/components/layout/AppToolbar.vue
index 8b8602e008..37fa34ef55 100644
--- a/apps/desktop/src/components/layout/AppToolbar.vue
+++ b/apps/desktop/src/components/layout/AppToolbar.vue
@@ -40,6 +40,7 @@ const props = defineProps<{
showDriverStore: boolean;
showSettingsPage: boolean;
checkingUpdates: boolean;
+ updateVersion?: string;
hasUpdateAvailable: boolean;
isDownloadingUpdate: boolean;
downloadProgress: number | null;
@@ -75,15 +76,12 @@ const { toast } = useToast();
const settingsStore = useSettingsStore();
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
-const checkingUpdates = computed(() => props.checkingUpdates);
const updateTooltip = computed(() => {
- if (props.isDownloadingUpdate) return t("updates.downloading", { progress: props.downloadProgress ?? 0 });
- if (props.updateReady) return t("updates.restartRequiredTooltip");
- if (props.updateReadyToInstall) return t("updates.updateReadyTooltip");
+ if (props.hasUpdateAvailable && props.updateReady) return t("updates.restartRequiredTooltip");
+ if (props.hasUpdateAvailable && props.updateReadyToInstall) return t("updates.downloadedReady", { version: props.updateVersion ?? "" });
return t("updates.check");
});
-// ToolbarUpdateIcon takes a 0..1 fraction, with null meaning the download size is unknown.
-const updateDownloadProgress = computed(() => (props.downloadProgress == null ? null : Math.min(1, Math.max(0, props.downloadProgress / 100))));
+
const sqlLibrarySaveFeedbackActive = ref(false);
const SQL_LIBRARY_BOOKMARK_PATH = "M10 2 L10 10 L13 7 L16 10 L16 2";
const SQL_LIBRARY_CHECK_PATH = "M9 9.5 L9 9.5 L11 11.5 L15 7.5 L15 7.5";
@@ -199,7 +197,7 @@ const collapsibleRightItemDefs = computed(() => {
label: t("updates.check"),
icon: CloudDownload,
action: () => emit("check-updates"),
- disabled: checkingUpdates.value,
+ disabled: false,
});
}
items.push({
@@ -612,10 +610,9 @@ const toolbarStyle = computed(() => {
-
{{ updateTooltip }}
diff --git a/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue b/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue
index 3cbbc5a466..1d91675475 100644
--- a/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue
+++ b/apps/desktop/src/components/layout/ToolbarUpdateIcon.vue
@@ -1,82 +1,11 @@
-
-
-
-
-
-
-
+
+
-
-
diff --git a/apps/desktop/src/components/layout/UpdateDialog.spec.ts b/apps/desktop/src/components/layout/UpdateDialog.spec.ts
index 095a9e495b..79b0249b26 100644
--- a/apps/desktop/src/components/layout/UpdateDialog.spec.ts
+++ b/apps/desktop/src/components/layout/UpdateDialog.spec.ts
@@ -14,6 +14,7 @@ const mountedApps: App[] = [];
interface DialogState {
open: boolean;
portableMode: boolean;
+ releaseNotes: string;
manualUpdateOnly: boolean;
isDownloadingUpdate: boolean;
downloadProgress: number | null;
@@ -32,6 +33,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial
-
+
{{ updateCheckMessage || t("updates.upToDate", { version: updateInfo?.current_version || "" }) }}
{{ t("updates.activeTasksBlockUpdate", { count: activeTaskCount }) }}
+ {{ t("updates.checking") }}
+ {{ t("updates.downloadedReady", { version: updateInfo?.latest_version }) }}
+ {{ updateCheckMessage }}
-
+
{{ t("updates.source") }}
-
- {{ t("updates.restart") }}
+ {{ t("updates.restart") }}
- {{ t("updates.installing") }}
+ {{ t(isPreparingUpdate ? "updates.preparing" : "updates.installing") }}
{{ t("updates.cancelDownload") }}
@@ -177,8 +193,8 @@ watch(
{{ t("updates.downloading", { progress: downloadProgress ?? 0 }) }}
- {{ t("updates.exitAndUpdate") }}
- {{ t("updates.downloadInBackground") }}
+ {{ t("updates.restartAndUpdate") }}
+ {{ t("updates.retryDownload") }}
diff --git a/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts b/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts
index 98622a3c5a..e877db39f2 100644
--- a/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/ToolbarUpdateIcon.spec.ts
@@ -1,89 +1,14 @@
// @vitest-environment happy-dom
-
-import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
-import { afterEach, describe, expect, it } from "vitest";
+import { createApp, h } from "vue";
+import { describe, expect, it } from "vitest";
import ToolbarUpdateIcon from "../ToolbarUpdateIcon.vue";
-
-const mountedApps: App[] = [];
-
-afterEach(() => {
- for (const app of mountedApps.splice(0)) app.unmount();
- document.body.innerHTML = "";
-});
-
-describe("ToolbarUpdateIcon", () => {
- it("uses cloud download while idle and a cloud outline scan while checking", async () => {
- const state = reactive({ loading: false });
+describe("silent toolbar update icon", () => {
+ it.each([{ loading: false }, { loading: true }, { downloading: true, progress: 0.4 }, { downloading: true, progress: null }])("keeps the ordinary icon for %o", (props) => {
const container = document.createElement("div");
- document.body.append(container);
- const app = createApp(
- defineComponent({
- setup: () => () => h(ToolbarUpdateIcon, { loading: state.loading }),
- }),
- );
- mountedApps.push(app);
+ const app = createApp({ render: () => h(ToolbarUpdateIcon, props) });
app.mount(container);
- await nextTick();
-
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
- expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
-
- state.loading = true;
- await nextTick();
- expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
- expect(container.querySelector("[data-toolbar-update-cloud]")).not.toBeNull();
- expect(container.querySelector("[data-toolbar-update-scan]")).not.toBeNull();
- });
-
- it("shows a progress ring while downloading, growing with progress", async () => {
- const state = reactive({ downloading: true, progress: 0.42 });
- const container = document.createElement("div");
- document.body.append(container);
- const app = createApp(
- defineComponent({
- setup: () => () => h(ToolbarUpdateIcon, { loading: false, downloading: state.downloading, progress: state.progress }),
- }),
- );
- mountedApps.push(app);
- app.mount(container);
- await nextTick();
-
- const ring = container.querySelector("[data-toolbar-update-progress]");
- expect(ring).not.toBeNull();
- expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(false);
- expect(ring?.style.background).toBe("conic-gradient(var(--primary) 42%, transparent 42%)");
- // The idle and checking glyphs stay hidden behind the ring.
- expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
- expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
-
- state.progress = 1;
- await nextTick();
- expect(ring?.style.background).toBe("conic-gradient(var(--primary) 100%, transparent 100%)");
-
- state.downloading = false;
- await nextTick();
- expect(container.querySelector("[data-toolbar-update-progress]")).toBeNull();
- expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
- });
-
- it("falls back to a rotating indeterminate arc when the download size is unknown", async () => {
- const state = reactive({ downloading: true, progress: null as number | null });
- const container = document.createElement("div");
- document.body.append(container);
- const app = createApp(
- defineComponent({
- setup: () => () => h(ToolbarUpdateIcon, { loading: true, downloading: state.downloading, progress: state.progress }),
- }),
- );
- mountedApps.push(app);
- app.mount(container);
- await nextTick();
-
- const ring = container.querySelector("[data-toolbar-update-progress]");
- expect(ring).not.toBeNull();
- // Indeterminate downloading outranks the checking animation.
- expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
- expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(true);
- expect(ring?.style.background).toBe("conic-gradient(var(--primary) 25%, transparent 25%)");
+ expect(container.querySelector("[data-toolbar-update-progress], [data-toolbar-update-scan]")).toBeNull();
+ app.unmount();
});
});
diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue
index cf7ce75f9f..a4e7ce97fb 100644
--- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue
+++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue
@@ -1,4 +1,5 @@
diff --git a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts
index 1b7887e71b..ea4aebde46 100644
--- a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts
+++ b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleDelete.spec.ts
@@ -16,7 +16,10 @@ const mocks = vi.hoisted(() => ({
toast: vi.fn(),
}));
-vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
+vi.mock("vue-i18n", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useI18n: () => ({ t: (key: string) => key }),
+}));
vi.mock("@/lib/backend/api", () => ({
nacosDeleteConfig: mocks.nacosDeleteConfig,
diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue
index 4002d0fb10..dd933a6273 100644
--- a/apps/desktop/src/components/redis/RedisValueViewer.vue
+++ b/apps/desktop/src/components/redis/RedisValueViewer.vue
@@ -1,4 +1,5 @@
diff --git a/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts b/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
index eb8d07f1d0..d927806883 100644
--- a/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useAppUpdater.spec.ts
@@ -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>(),
- downloadUpdate: vi.fn<() => Promise>(),
- installDownloadedUpdate: vi.fn<() => Promise>(),
+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>(),
-}));
-vi.mock("@/stores/settingsStore", () => ({
- useSettingsStore: () => settingsStoreMock,
-}));
-vi.mock("@tauri-apps/api/event", () => ({
- listen: listenMock,
-}));
-
-interface Deferred {
- promise: Promise;
- resolve: (value: T | PromiseLike) => void;
- reject: (reason?: unknown) => void;
-}
-
-function deferred(): Deferred {
- let resolve!: Deferred["resolve"];
- let reject!: Deferred["reject"];
- const promise = new Promise((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[0] = {}) {
let updater!: ReturnType;
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() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((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();
- const secondDownload = deferred();
- const cancellation = deferred();
- 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();
+ 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();
- 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();
- 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();
+ 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();
+ 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();
+ 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();
- 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();
- 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();
+ const pendingCancel = deferred();
+ 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);
});
});
diff --git a/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts
index 1bc524e377..96ce8befa0 100644
--- a/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useSidebarConnectionMutationRuntime.selection.spec.ts
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({
toast: vi.fn(),
}));
-vi.mock("vue-i18n", () => ({
+vi.mock("vue-i18n", async (importOriginal) => ({
+ ...(await importOriginal()),
useI18n: () => ({
t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key),
}),
diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts
index 113d1de65f..887738dfa4 100644
--- a/apps/desktop/src/composables/useAppUpdater.ts
+++ b/apps/desktop/src/composables/useAppUpdater.ts
@@ -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 {
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("idle");
+ const downloaded = ref(null);
const updateInfo = ref(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(0);
- const updateDownloaded = ref(false);
- const isInstallingUpdate = ref(false);
- const updateReady = ref(false);
+ const downloadProgress = ref(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 | undefined;
+ let generation = 0;
+ let activeDownload: Promise | undefined;
+ let cancellation: Promise | undefined;
+ let cancelOperation: Promise | undefined;
+ let retryTimer: ReturnType | undefined;
+ let hourlyTimer: ReturnType | 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("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 {
+ 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("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,
};
}
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts
index ca26213d72..603d0df355 100644
--- a/apps/desktop/src/i18n/locales/az.ts
+++ b/apps/desktop/src/i18n/locales/az.ts
@@ -285,6 +285,19 @@ export default withEnglishFallback({
changedAgain: "Fayl yadda saxlanılarkən yenidən dəyişdirildi. Yenidən cəhd edin.",
},
updates: {
+ preparationWindowOperation: "Yeniləmədən əvvəl cari pəncərə əməliyyatının bitməsini gözləyin.",
+ preparationBusy: "Yeniləmə artıq hazırlanır.",
+ preparationCancelled: "Yeniləmə hazırlığı ləğv edildi.",
+ preparationTimeout: "Pəncərə 5 saniyə ərzində saxlanmadı. Yenidən sınayın.",
+ preparationWindowsChanged: "Açıq pəncərələr dəyişdi. Yenidən sınayın.",
+ preparationTasks: "Aktiv tapşırıqları və tranzaksiyaları bitirin.",
+ preparationDrafts: "Dəyişiklikləri saxlayın və ya ləğv edin.",
+ preparationConfig: "Konfiqurasiya redaktəsini bitirin.",
+ preparationNotReady: "Yeniləmə hazır deyil. Yenidən sınayın.",
+ downloadedReady: "v{version} versiyası endirilib. Yeniləmək üçün yenidən başladın.",
+ restartAndUpdate: "Yenidən başladın və yeniləyin",
+ preparing: "Yeniləmə hazırlanır…",
+ retryDownload: "Endirməni yenidən sınayın",
title: "Yeniləmələr",
check: "Yeniləmələri yoxla",
updateReadyTooltip: "Yeniləmə hazırdır — quraşdırmaq üçün klikləyin",
@@ -6743,7 +6756,7 @@ export default withEnglishFallback({
closeActionQuit: "DBX-dən çıx",
closeActionMinimize: "Sistem treyinə kiçilt",
updateNotificationsEnabled: "Yeniləmə xatırlatmalarını aktiv et",
- updateNotificationsEnabledDescription: "Deaktiv olduqda DBX tətbiq və ya drayver yeniləmələrini avtomatik yoxlamayacaq və yeniləmə nişanlarını göstərməyəcək. Əl ilə yoxlama yenə mümkündür.",
+ updateNotificationsEnabledDescription: "Tətbiq və sürücü yeniləmələrini avtomatik yoxlayır, tətbiq yeniləmələrini səssiz endirir. Söndürüldükdə avtomatik yoxlamalar, endirmələr və nişanlar dayanır. Əl ilə yoxlama və endirilmiş yeniləmələrin quraşdırılması mümkündür.",
updateDownloadSource: "Yeniləmələrin endirmə mənbəyi",
updateDownloadSourceDescription: "Tətbiq yeniləmələrinin, verilənlər bazası agentlərinin, drayverlərin və idarə olunan JRE-lərin haradan endiriləcəyini seçin. Rəsmi mənbə tövsiyə olunur; CNB materik Çin şəbəkələrində daha sürətli ola bilər.",
updateDownloadSourceOfficial: "Rəsmi mənbə (tövsiyə olunur)",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index dd708d7322..cb03cd4d47 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -286,6 +286,19 @@ export default {
changedAgain: "The file changed again while it was being saved. Try again.",
},
updates: {
+ preparationWindowOperation: "Please wait for the current window operation to finish before updating.",
+ preparationBusy: "An update is already being prepared.",
+ preparationCancelled: "Update preparation was cancelled.",
+ preparationTimeout: "A window did not save within 5 seconds. Try again.",
+ preparationWindowsChanged: "Open windows changed. Try the update again.",
+ preparationTasks: "Finish active tasks and transactions before updating.",
+ preparationDrafts: "Save or discard unsaved changes before updating.",
+ preparationConfig: "Finish editing the current configuration before updating.",
+ preparationNotReady: "Update preparation is not ready. Try again.",
+ downloadedReady: "New version v{version} is downloaded. Restart to update.",
+ restartAndUpdate: "Restart & Update",
+ preparing: "Preparing update…",
+ retryDownload: "Retry Download",
title: "Updates",
check: "Check for updates",
updateReadyTooltip: "Update ready — click to install",
@@ -6740,7 +6753,7 @@ export default {
closeActionQuit: "Quit DBX",
closeActionMinimize: "Minimize to tray",
updateNotificationsEnabled: "Enable update reminders",
- updateNotificationsEnabledDescription: "When disabled, DBX will not automatically check app or driver updates or show update badges. Manual checks are still available.",
+ updateNotificationsEnabledDescription: "Automatically check for app and driver updates and silently download app updates. When disabled, automatic checks, app downloads and update badges stop. You can still check manually and install downloaded updates.",
updateDownloadSource: "Update download source",
updateDownloadSourceDescription: "Choose where app updates, database agents, drivers, and managed JREs are downloaded from. The official source is recommended; CNB can be faster on mainland China networks.",
updateDownloadSourceOfficial: "Official source (recommended)",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 843e3f11f1..fc08ed92c6 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -288,6 +288,19 @@ export default withEnglishFallback({
changedAgain: "El archivo volvió a cambiar durante el guardado. Inténtalo de nuevo.",
},
updates: {
+ preparationWindowOperation: "Espera a que termine la operación de ventana actual antes de actualizar.",
+ preparationBusy: "Ya se está preparando una actualización.",
+ preparationCancelled: "Se canceló la preparación.",
+ preparationTimeout: "Una ventana no se guardó en 5 segundos. Reintenta.",
+ preparationWindowsChanged: "Las ventanas abiertas cambiaron. Reintenta.",
+ preparationTasks: "Finaliza las tareas y transacciones activas.",
+ preparationDrafts: "Guarda o descarta los cambios pendientes.",
+ preparationConfig: "Finaliza la edición de configuración.",
+ preparationNotReady: "La actualización no está lista. Reintenta.",
+ downloadedReady: "La versión v{version} está descargada. Reinicia para actualizar.",
+ restartAndUpdate: "Reiniciar y actualizar",
+ preparing: "Preparando actualización…",
+ retryDownload: "Reintentar descarga",
title: "Actualizaciones",
check: "Buscar actualizaciones",
updateReadyTooltip: "Actualización lista, haz clic para instalar",
@@ -6314,7 +6327,7 @@ export default withEnglishFallback({
closeActionQuit: "Salir de DBX",
closeActionMinimize: "Minimizar a la bandeja",
updateNotificationsEnabled: "Activar recordatorios de actualización",
- updateNotificationsEnabledDescription: "Al desactivarlo, DBX no comprobará automáticamente actualizaciones de la app ni de los controladores, ni mostrará indicadores. Las comprobaciones manuales siguen disponibles.",
+ updateNotificationsEnabledDescription: "Comprueba actualizaciones de la app y los controladores y descarga las de la app en silencio. Al desactivar se detienen las comprobaciones, descargas e indicadores. Puedes comprobar manualmente e instalar actualizaciones descargadas.",
updateDownloadSource: "Origen de descarga de actualizaciones",
updateDownloadSourceDescription: "Elige de dónde se descargan los instaladores de actualización. Se recomienda la fuente oficial; CNB puede ser más rápido en redes de China continental.",
updateDownloadSourceOfficial: "Fuente oficial (recomendada)",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 85d7090f5d..fa1a31e13a 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -287,6 +287,19 @@ export default withEnglishFallback({
changedAgain: "Il file è cambiato di nuovo durante il salvataggio. Riprova.",
},
updates: {
+ preparationWindowOperation: "Attendi il completamento dell’operazione sulla finestra prima di aggiornare.",
+ preparationBusy: "Un aggiornamento è già in preparazione.",
+ preparationCancelled: "Preparazione annullata.",
+ preparationTimeout: "Una finestra non ha salvato entro 5 secondi. Riprova.",
+ preparationWindowsChanged: "Le finestre aperte sono cambiate. Riprova.",
+ preparationTasks: "Termina attività e transazioni in corso.",
+ preparationDrafts: "Salva o scarta le modifiche non salvate.",
+ preparationConfig: "Completa la modifica della configurazione.",
+ preparationNotReady: "Aggiornamento non pronto. Riprova.",
+ downloadedReady: "La versione v{version} è scaricata. Riavvia per aggiornare.",
+ restartAndUpdate: "Riavvia e aggiorna",
+ preparing: "Preparazione aggiornamento…",
+ retryDownload: "Riprova download",
title: "Aggiornamenti",
check: "Verifica aggiornamenti",
updateReadyTooltip: "Aggiornamento pronto, clicca per installare",
@@ -6314,7 +6327,7 @@ export default withEnglishFallback({
closeActionQuit: "Esci da DBX",
closeActionMinimize: "Riduci a icona",
updateNotificationsEnabled: "Abilita promemoria aggiornamenti",
- updateNotificationsEnabledDescription: "Se disattivato, DBX non controllerà automaticamente gli aggiornamenti dell'app o dei driver e non mostrerà badge. I controlli manuali restano disponibili.",
+ updateNotificationsEnabledDescription: "Controlla gli aggiornamenti di app e driver e scarica quelli dell’app senza notifiche. Disattivando, controlli automatici, download e indicatori si fermano. Restano disponibili controlli manuali e installazione degli aggiornamenti scaricati.",
updateDownloadSource: "Fonte download aggiornamenti",
updateDownloadSourceDescription: "Scegli da dove scaricare gli installer degli aggiornamenti. La fonte ufficiale è consigliata; CNB può essere più veloce sulle reti della Cina continentale.",
updateDownloadSourceOfficial: "Fonte ufficiale (consigliata)",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 011e093ed7..21c97dbb06 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -288,6 +288,19 @@ export default withEnglishFallback({
changedAgain: "保存中にファイルが再度変更されました。もう一度お試しください。",
},
updates: {
+ preparationWindowOperation: "更新する前に、現在のウィンドウ操作が完了するまでお待ちください。",
+ preparationBusy: "更新を準備中です。",
+ preparationCancelled: "更新準備をキャンセルしました。",
+ preparationTimeout: "5 秒以内に保存できないウィンドウがあります。再試行してください。",
+ preparationWindowsChanged: "ウィンドウ構成が変わりました。再試行してください。",
+ preparationTasks: "実行中のタスクとトランザクションを終了してください。",
+ preparationDrafts: "未保存の変更を保存または破棄してください。",
+ preparationConfig: "設定の編集を終了してください。",
+ preparationNotReady: "更新の準備ができていません。再試行してください。",
+ downloadedReady: "新バージョン v{version} をダウンロード済みです。再起動して更新できます。",
+ restartAndUpdate: "再起動して更新",
+ preparing: "更新を準備中…",
+ retryDownload: "ダウンロードを再試行",
title: "アップデート",
check: "アップデートを確認",
updateReadyTooltip: "アップデートの準備ができました。クリックしてインストール",
@@ -6352,7 +6365,7 @@ export default withEnglishFallback({
closeActionQuit: "DBXを終了",
closeActionMinimize: "トレイに最小化",
updateNotificationsEnabled: "アップデート通知を有効にする",
- updateNotificationsEnabledDescription: "無効時、DBXはアプリやドライバーのアップデートを自動確認せず、アップデートバッジも表示しません。手動確認は引き続き利用可能です。",
+ updateNotificationsEnabledDescription: "アプリとドライバーの更新を自動確認し、アプリ更新を通知せずにダウンロードします。無効にすると自動確認、ダウンロード、更新バッジが停止します。手動確認とダウンロード済み更新のインストールは可能です。",
debugLoggingEnabled: "デバッグログを有効にする",
debugLoggingEnabledDescription: "トラブルシューティング時に、より詳細なローカル診断ログを記録します。デフォルトでは無効です。",
metadataCacheMemoryLimit: "メタデータメモリキャッシュ上限",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index c6229015fa..bc0265ad80 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -286,6 +286,19 @@ export default withEnglishFallback({
changedAgain: "저장하는 동안 파일이 다시 변경되었습니다. 다시 시도하세요.",
},
updates: {
+ preparationWindowOperation: "업데이트하기 전에 현재 창 작업이 완료될 때까지 기다려 주세요.",
+ preparationBusy: "업데이트를 준비 중입니다.",
+ preparationCancelled: "업데이트 준비가 취소되었습니다.",
+ preparationTimeout: "창이 5초 안에 저장되지 않았습니다. 다시 시도하세요.",
+ preparationWindowsChanged: "열린 창이 변경되었습니다. 다시 시도하세요.",
+ preparationTasks: "실행 중인 작업과 트랜잭션을 종료하세요.",
+ preparationDrafts: "변경 사항을 저장하거나 버리세요.",
+ preparationConfig: "현재 설정 편집을 완료하세요.",
+ preparationNotReady: "업데이트 준비가 안 되었습니다. 다시 시도하세요.",
+ downloadedReady: "새 버전 v{version} 다운로드 완료. 다시 시작하여 업데이트하세요.",
+ restartAndUpdate: "다시 시작 및 업데이트",
+ preparing: "업데이트 준비 중…",
+ retryDownload: "다운로드 재시도",
title: "업데이트",
check: "업데이트 확인",
updateReadyTooltip: "업데이트 준비 완료 — 클릭하여 설치",
@@ -6057,7 +6070,7 @@ export default withEnglishFallback({
closeActionQuit: "DBX 종료",
closeActionMinimize: "트레이로 최소화",
updateNotificationsEnabled: "업데이트 알림 활성화",
- updateNotificationsEnabledDescription: "비활성화하면 DBX가 앱이나 드라이버 업데이트를 자동으로 확인하거나 업데이트 배지를 표시하지 않습니다. 수동 확인은 여전히 가능합니다.",
+ updateNotificationsEnabledDescription: "앱과 드라이버 업데이트를 자동 확인하고 앱 업데이트를 조용히 다운로드합니다. 끄면 자동 확인, 앱 다운로드와 배지가 중지됩니다. 수동 확인 및 다운로드된 업데이트 설치는 가능합니다.",
updateDownloadSource: "업데이트 다운로드 소스",
updateDownloadSourceDescription: "앱 업데이트, 데이터베이스 에이전트, 드라이버, 관리 JRE를 다운로드할 위치를 선택하세요. 공식 소스를 권장합니다. 중국 본토 네트워크에서는 CNB가 더 빠를 수 있습니다.",
updateDownloadSourceOfficial: "공식 소스 (권장)",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index c2434024c8..a849ffaeeb 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -288,6 +288,19 @@ export default withEnglishFallback({
changedAgain: "O arquivo mudou novamente durante o salvamento. Tente outra vez.",
},
updates: {
+ preparationWindowOperation: "Aguarde a operação atual da janela terminar antes de atualizar.",
+ preparationBusy: "Uma atualização já está sendo preparada.",
+ preparationCancelled: "Preparação cancelada.",
+ preparationTimeout: "Uma janela não salvou em 5 segundos. Tente novamente.",
+ preparationWindowsChanged: "As janelas abertas mudaram. Tente novamente.",
+ preparationTasks: "Conclua as tarefas e transações ativas.",
+ preparationDrafts: "Salve ou descarte as alterações pendentes.",
+ preparationConfig: "Conclua a edição da configuração.",
+ preparationNotReady: "A atualização não está pronta. Tente novamente.",
+ downloadedReady: "A versão v{version} foi baixada. Reinicie para atualizar.",
+ restartAndUpdate: "Reiniciar e atualizar",
+ preparing: "Preparando atualização…",
+ retryDownload: "Tentar baixar novamente",
title: "Atualizações",
check: "Verificar atualizações",
updateReadyTooltip: "Atualização pronta, clique para instalar",
@@ -6316,7 +6329,7 @@ export default withEnglishFallback({
closeActionQuit: "Sair do DBX",
closeActionMinimize: "Minimizar para a bandeja",
updateNotificationsEnabled: "Ativar lembretes de atualização",
- updateNotificationsEnabledDescription: "Quando desativado, o DBX não verificará automaticamente atualizações do app ou dos drivers nem mostrará indicadores. Verificações manuais continuam disponíveis.",
+ updateNotificationsEnabledDescription: "Verifica atualizações do app e dos drivers e baixa as do app silenciosamente. Ao desativar, as verificações, downloads e indicadores param. Você ainda pode verificar manualmente e instalar atualizações baixadas.",
updateDownloadSource: "Fonte de download de atualização",
updateDownloadSourceDescription: "Escolha de onde os instaladores de atualização no aplicativo são baixados. A fonte oficial é recomendada; CNB pode ser mais rápido em redes da China continental.",
updateDownloadSourceOfficial: "Fonte oficial (recomendada)",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index c100f8e1c2..7f4c320f1b 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -287,6 +287,19 @@ export default withEnglishFallback({
changedAgain: "Dosya kaydedilirken yeniden değişti. Tekrar deneyin.",
},
updates: {
+ preparationWindowOperation: "Güncellemeden önce geçerli pencere işleminin tamamlanmasını bekleyin.",
+ preparationBusy: "Güncelleme zaten hazırlanıyor.",
+ preparationCancelled: "Güncelleme hazırlığı iptal edildi.",
+ preparationTimeout: "Bir pencere 5 saniyede kaydetmedi. Yeniden deneyin.",
+ preparationWindowsChanged: "Açık pencereler değişti. Yeniden deneyin.",
+ preparationTasks: "Etkin görevleri ve işlemleri bitirin.",
+ preparationDrafts: "Kaydedilmemiş değişiklikleri kaydedin veya atın.",
+ preparationConfig: "Yapılandırma düzenlemesini bitirin.",
+ preparationNotReady: "Güncelleme hazır değil. Yeniden deneyin.",
+ downloadedReady: "v{version} sürümü indirildi. Güncellemek için yeniden başlatın.",
+ restartAndUpdate: "Yeniden başlat ve güncelle",
+ preparing: "Güncelleme hazırlanıyor…",
+ retryDownload: "İndirmeyi yeniden dene",
title: "Güncellemeler",
check: "Güncellemeleri denetle",
updateReadyTooltip: "Güncelleme hazır — kurmak için tıklayın",
@@ -6625,7 +6638,7 @@ export default withEnglishFallback({
closeActionQuit: "DBX'ten çık",
closeActionMinimize: "Tepsiye küçült",
updateNotificationsEnabled: "Güncelleme anımsatıcılarını etkinleştir",
- updateNotificationsEnabledDescription: "Kapatıldığında DBX, uygulama veya sürücü güncellemelerini otomatik denetlemez ve güncelleme rozetleri göstermez. Elle denetim yine kullanılabilir.",
+ updateNotificationsEnabledDescription: "Uygulama ve sürücü güncellemelerini otomatik kontrol eder, uygulama güncellemelerini sessizce indirir. Kapatıldığında otomatik kontroller, indirmeler ve rozetler durur. Elle kontrol ve indirilen güncellemeleri yükleme kullanılabilir.",
updateDownloadSource: "Güncelleme indirme kaynağı",
updateDownloadSourceDescription: "Uygulama güncellemelerinin, veritabanı agent'larının, sürücülerin ve yönetilen JRE'lerin nereden indirileceğini seçin. Resmî kaynak önerilir; Çin anakarası ağlarında CNB daha hızlı olabilir.",
updateDownloadSourceOfficial: "Resmî kaynak (önerilen)",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index a53d2d80a9..eb9fc63fca 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -211,6 +211,19 @@ export default withEnglishFallback({
changedAgain: "保存过程中该文件再次发生变化,请重试。",
},
updates: {
+ preparationWindowOperation: "请等待当前窗口操作完成后再更新。",
+ preparationBusy: "正在准备更新,请稍候。",
+ preparationCancelled: "更新准备已取消。",
+ preparationTimeout: "有窗口未在 5 秒内完成保存,请重试。",
+ preparationWindowsChanged: "打开的窗口发生变化,请重新尝试更新。",
+ preparationTasks: "请先结束正在运行的任务和事务。",
+ preparationDrafts: "请先保存或放弃尚未保存的修改。",
+ preparationConfig: "请先完成当前配置编辑。",
+ preparationNotReady: "更新准备尚未就绪,请重试。",
+ downloadedReady: "新版本 v{version} 已下载,重启即可更新",
+ restartAndUpdate: "重启并更新",
+ preparing: "正在准备更新…",
+ retryDownload: "重试下载",
title: "更新",
check: "检查更新",
updateReadyTooltip: "更新已就绪,点击安装",
@@ -6723,7 +6736,7 @@ export default withEnglishFallback({
closeActionQuit: "退出程序",
closeActionMinimize: "最小化到托盘",
updateNotificationsEnabled: "启用更新提醒",
- updateNotificationsEnabledDescription: "关闭后,DBX 不会自动检查应用和驱动更新,也不会显示更新红点;仍可手动检查更新。",
+ updateNotificationsEnabledDescription: "自动检查应用和驱动更新,并静默下载应用更新。关闭后停止自动检查、应用下载和更新红点;仍可手动检查并安装已下载的更新。",
updateDownloadSource: "更新下载源",
updateDownloadSourceDescription: "选择应用更新、数据库 Agent、驱动和托管 JRE 的下载来源。官方源为推荐选项,CNB 适合国内网络环境。",
updateDownloadSourceOfficial: "官方源(推荐)",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index eed99274bd..ff11ad0369 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -288,6 +288,19 @@ export default withEnglishFallback({
changedAgain: "儲存期間檔案再次發生變更,請重試。",
},
updates: {
+ preparationWindowOperation: "請等待目前視窗操作完成後再更新。",
+ preparationBusy: "正在準備更新,請稍候。",
+ preparationCancelled: "更新準備已取消。",
+ preparationTimeout: "有視窗未在 5 秒內完成儲存,請重試。",
+ preparationWindowsChanged: "開啟的視窗發生變化,請重新嘗試更新。",
+ preparationTasks: "請先結束正在執行的工作和交易。",
+ preparationDrafts: "請先儲存或捨棄尚未儲存的修改。",
+ preparationConfig: "請先完成目前設定編輯。",
+ preparationNotReady: "更新準備尚未就緒,請重試。",
+ downloadedReady: "新版本 v{version} 已下載,重新啟動即可更新",
+ restartAndUpdate: "重新啟動並更新",
+ preparing: "正在準備更新…",
+ retryDownload: "重試下載",
title: "更新",
check: "檢查更新",
updateReadyTooltip: "更新已就緒,點擊安裝",
@@ -5633,7 +5646,7 @@ export default withEnglishFallback({
closeActionQuit: "退出程式",
closeActionMinimize: "最小化到系統匣",
updateNotificationsEnabled: "啟用更新提醒",
- updateNotificationsEnabledDescription: "關閉後,DBX 不會自動檢查應用程式和驅動程式更新,也不會顯示更新紅點;仍可手動檢查更新。",
+ updateNotificationsEnabledDescription: "自動檢查應用程式和驅動程式更新,並靜默下載應用程式更新。關閉後停止自動檢查、應用程式下載和更新紅點;仍可手動檢查並安裝已下載的更新。",
debugLoggingEnabled: "啟用偵錯日誌",
debugLoggingEnabledDescription: "開啟後在本機記錄更詳細的使用者側診斷日誌,回報問題時可暫時開啟;預設關閉。",
metadataCacheMemoryLimit: "中繼資料記憶體快取上限",
diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts
index 319dfbf84e..eb07378143 100644
--- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts
+++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts
@@ -21,6 +21,12 @@ function roundTrip(tabs: QueryTab[]) {
}
describe("openTabsPersistence originalSql round-trip", () => {
+ it("keeps clean saved SQL tabs eligible for file hydration", () => {
+ const [restored] = roundTrip([queryTab({ savedSqlId: "saved", sql: "SELECT 1", originalSql: "SELECT 1" })]);
+ expect(restored.sql).toBe("");
+ expect(restored.originalSql).toBeUndefined();
+ });
+
it("preserves read-only source intent without adding editable source metadata", () => {
const [restored] = roundTrip([queryTab({ sourceView: true, sql: "CREATE SEQUENCE seq_users" })]);
expect(restored.sourceView).toBe(true);
diff --git a/apps/desktop/src/lib/ai/desktopAiRunRegistry.ts b/apps/desktop/src/lib/ai/desktopAiRunRegistry.ts
index c02036ec03..6de85f60fa 100644
--- a/apps/desktop/src/lib/ai/desktopAiRunRegistry.ts
+++ b/apps/desktop/src/lib/ai/desktopAiRunRegistry.ts
@@ -128,6 +128,11 @@ export function hasActiveDesktopAiRun(conversationId: string): boolean {
return run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation" || run.status === "pending_recoverable";
}
+/** Restart has no opportunity to recover an in-memory AI run after process exit. */
+export function blockingDesktopAiRunsForUpdate(): DesktopAiRunRuntime[] {
+ return [...runsByConversation.values()].filter((run) => !isTerminalDesktopAiRunStatus(run.status));
+}
+
export function blockingDesktopAiRunsForQuit(): DesktopAiRunRuntime[] {
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running");
}
diff --git a/apps/desktop/src/lib/app/__tests__/detachedTabWindow.update.spec.ts b/apps/desktop/src/lib/app/__tests__/detachedTabWindow.update.spec.ts
new file mode 100644
index 0000000000..595a040ae7
--- /dev/null
+++ b/apps/desktop/src/lib/app/__tests__/detachedTabWindow.update.spec.ts
@@ -0,0 +1,87 @@
+// @vitest-environment happy-dom
+import { readFileSync } from "node:fs";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { acquireUpdateBarrier, assertUpdateSafe } from "../updatePreparation";
+import { openDetachedTabWindow } from "../detachedTabWindow";
+const mocks = vi.hoisted(() => ({ getByLabel: vi.fn(), created: vi.fn(), callbacks: new Map unknown>(), show: vi.fn(), focus: vi.fn(), destroy: vi.fn() }));
+vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
+vi.mock("@/lib/app/windowContext", () => ({ detachedWindowLabel: (id: string) => `detached-${id}`, detachedWindowUrl: () => "http://localhost/" }));
+vi.mock("@tauri-apps/api/webviewWindow", () => ({
+ WebviewWindow: class {
+ static getByLabel = mocks.getByLabel;
+ constructor() {
+ mocks.created();
+ }
+ once(event: string, callback: (event?: unknown) => unknown) {
+ mocks.callbacks.set(event, callback);
+ return Promise.resolve(() => {});
+ }
+ show = mocks.show;
+ setFocus = mocks.focus;
+ destroy = mocks.destroy;
+ },
+}));
+afterEach(() => {
+ vi.clearAllMocks();
+ mocks.callbacks.clear();
+ vi.useRealTimers();
+});
+function pendingLookup() {
+ let resolve!: (value: unknown) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ mocks.getByLabel.mockReturnValueOnce(promise);
+ return resolve;
+}
+describe("detached window update exclusion", () => {
+ it("grants the capability required to remove never-shown late windows", () => {
+ const capability = JSON.parse(readFileSync("src-tauri/capabilities/default.json", "utf8"));
+ expect(capability.permissions).toContain("core:window:allow-destroy");
+ });
+
+ it("blocks update preparation while the asynchronous existing-window lookup is pending", async () => {
+ const resolve = pendingLookup();
+ const operation = openDetachedTabWindow("one", "SQL");
+ await vi.waitFor(() => expect(mocks.getByLabel).toHaveBeenCalled());
+ expect(assertUpdateSafe).toThrow("window operation");
+ resolve({ show: async () => {}, setFocus: async () => {} });
+ expect(await operation).toEqual({ opened: true });
+ expect(assertUpdateSafe).not.toThrow();
+ });
+ it("does not create a window if preparation starts during the awaited lookup", async () => {
+ const resolve = pendingLookup();
+ const operation = openDetachedTabWindow("two", "SQL");
+ await vi.waitFor(() => expect(mocks.getByLabel).toHaveBeenCalled());
+ const release = acquireUpdateBarrier();
+ resolve(null);
+ const result = await operation;
+ expect(result.opened).toBe(false);
+ expect(result.error).toContain("Update preparation");
+ expect(mocks.created).not.toHaveBeenCalled();
+ release();
+ expect(assertUpdateSafe).not.toThrow();
+ });
+ it("keeps a timed-out native creation blocked and closes a late child without showing it", async () => {
+ vi.useFakeTimers();
+ mocks.getByLabel.mockResolvedValueOnce(null);
+ mocks.destroy.mockResolvedValue(undefined);
+ const operation = openDetachedTabWindow("late", "SQL");
+ await vi.waitFor(() => expect(mocks.created).toHaveBeenCalled());
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect((await operation).opened).toBe(false);
+ expect(assertUpdateSafe).toThrow("window operation");
+ await mocks.callbacks.get("tauri://created")?.();
+ expect(mocks.destroy).toHaveBeenCalledOnce();
+ expect(mocks.show).not.toHaveBeenCalled();
+ expect(mocks.focus).not.toHaveBeenCalled();
+ expect(assertUpdateSafe).not.toThrow();
+ });
+ it("rejects a new detach before starting native lookup while preparation is active", async () => {
+ const release = acquireUpdateBarrier();
+ expect((await openDetachedTabWindow("three", "SQL")).opened).toBe(false);
+ expect(mocks.getByLabel).not.toHaveBeenCalled();
+ release();
+ expect(assertUpdateSafe).not.toThrow();
+ });
+});
diff --git a/apps/desktop/src/lib/app/__tests__/updatePreparation.spec.ts b/apps/desktop/src/lib/app/__tests__/updatePreparation.spec.ts
new file mode 100644
index 0000000000..ece334c434
--- /dev/null
+++ b/apps/desktop/src/lib/app/__tests__/updatePreparation.spec.ts
@@ -0,0 +1,155 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { effectScope } from "vue";
+import { acquireUpdateBarrier, beginUpdateSensitiveOperation, prepareUpdateWithDraftRecovery, UPDATE_RESTORE_KEY, assertUpdateAllowsCommand, assertUpdateSafe, setupUpdatePreparation, useUpdateBlocker } from "../updatePreparation";
+import i18n, { loadLocaleMessages } from "@/i18n";
+
+const bus = vi.hoisted(() => ({ label: "main", members: ["main"] as string[], listeners: new Map unknown }>>() }));
+vi.mock("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow: () => ({ label: bus.label }), getAllWebviewWindows: async () => bus.members.map((label) => ({ label })) }));
+vi.mock("@tauri-apps/api/event", () => ({
+ listen: async (event: string, callback: (event: any) => unknown) => {
+ const entry = { label: bus.label, callback };
+ const listeners = bus.listeners.get(event) ?? new Set();
+ listeners.add(entry);
+ bus.listeners.set(event, listeners);
+ return () => listeners.delete(entry);
+ },
+ emit: async (event: string, payload: unknown) => {
+ for (const listener of bus.listeners.get(event) ?? []) void listener.callback({ payload });
+ },
+ emitTo: async (label: string, event: string, payload: unknown) => {
+ for (const listener of bus.listeners.get(event) ?? []) if (listener.label === label) void listener.callback({ payload });
+ },
+}));
+afterEach(() => {
+ bus.listeners.clear();
+ bus.members = ["main"];
+ bus.label = "main";
+ vi.useRealTimers();
+});
+
+describe("update restart preparation", () => {
+ it("translates a pending window operation using the current locale", async () => {
+ const previousLocale = i18n.global.locale.value;
+ await loadLocaleMessages("zh-CN");
+ const release = beginUpdateSensitiveOperation();
+ try {
+ i18n.global.locale.value = "zh-CN";
+ expect(assertUpdateSafe).toThrow("请等待当前窗口操作完成后再更新。");
+ i18n.global.locale.value = "en";
+ expect(assertUpdateSafe).toThrow("Please wait for the current window operation to finish before updating.");
+ } finally {
+ release();
+ i18n.global.locale.value = previousLocale;
+ }
+ });
+
+ it("marks recovery only after all preparation succeeds and preserves recovery when relaunch fails", async () => {
+ const storage = new Map();
+ const disk = {
+ setItem: (key: string, value: string) => {
+ storage.set(key, value);
+ },
+ };
+ await expect(
+ prepareUpdateWithDraftRecovery(async () => {
+ throw new Error("companion failed");
+ }, disk),
+ ).rejects.toThrow("companion failed");
+ expect(storage.has(UPDATE_RESTORE_KEY)).toBe(false);
+ const release = vi.fn();
+ const undo = await prepareUpdateWithDraftRecovery(async () => release, disk);
+ expect(storage.get(UPDATE_RESTORE_KEY)).toBe("1");
+ undo();
+ expect(storage.get(UPDATE_RESTORE_KEY)).toBe("1");
+ expect(release).toHaveBeenCalledOnce();
+ });
+ it("releases the barrier when saving the recovery marker fails", async () => {
+ const release = vi.fn();
+ const disk = {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("storage unavailable");
+ },
+ removeItem: vi.fn(),
+ };
+ await expect(prepareUpdateWithDraftRecovery(async () => release, disk)).rejects.toThrow("storage unavailable");
+ expect(release).toHaveBeenCalledOnce();
+ });
+
+ it("blocks native task commands and input until explicitly released", () => {
+ const release = acquireUpdateBarrier();
+ expect(() => assertUpdateAllowsCommand("execute_query")).toThrow();
+ expect(() => assertUpdateAllowsCommand("unknown_future_mutation")).toThrow();
+ expect(() => assertUpdateAllowsCommand("save_open_tabs_state")).not.toThrow();
+ expect(() => assertUpdateAllowsCommand("install_downloaded_update")).not.toThrow();
+ const event = new Event("beforeinput", { cancelable: true });
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ release();
+ release();
+ expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
+ });
+ it("removes editor vetoes when their scope is disposed", () => {
+ const scope = effectScope();
+ scope.run(() => useUpdateBlocker(() => "unsaved Redis value"));
+ expect(assertUpdateSafe).toThrow("unsaved Redis value");
+ scope.stop();
+ expect(assertUpdateSafe).not.toThrow();
+ });
+ it("waits for every window to save and holds the barrier until release", async () => {
+ bus.members = ["main", "detached-tab-one"];
+ const persist = vi.fn(async () => {});
+ const main = await setupUpdatePreparation({ assertSafe() {}, persist });
+ bus.label = "detached-tab-one";
+ const detached = await setupUpdatePreparation({ assertSafe() {}, persist });
+ const release = await main.prepare();
+ expect(persist).toHaveBeenCalledTimes(2);
+ expect(() => assertUpdateAllowsCommand("execute_query")).toThrow();
+ release();
+ expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
+ main.dispose();
+ detached.dispose();
+ });
+ it("releases all windows if a draft write fails", async () => {
+ const main = await setupUpdatePreparation({
+ assertSafe() {},
+ persist: async () => {
+ throw new Error("disk full");
+ },
+ });
+ await expect(main.prepare()).rejects.toThrow("disk full");
+ expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
+ main.dispose();
+ });
+ it("fails if a new window appears while saving", async () => {
+ const main = await setupUpdatePreparation({
+ assertSafe() {},
+ persist: async () => {
+ bus.members.push("late-window");
+ },
+ });
+ await expect(main.prepare()).rejects.toThrow("Open windows changed");
+ expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
+ main.dispose();
+ });
+ it("times out a hung local disk write even without other windows", async () => {
+ vi.useFakeTimers();
+ const main = await setupUpdatePreparation({ assertSafe() {}, persist: () => new Promise(() => {}) });
+ const result = expect(main.prepare()).rejects.toThrow("5 seconds");
+ await vi.advanceTimersByTimeAsync(5000);
+ await result;
+ expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
+ main.dispose();
+ });
+ it("times out an unresponsive window after five seconds", async () => {
+ vi.useFakeTimers();
+ bus.members.push("unresponsive");
+ const main = await setupUpdatePreparation({ assertSafe() {}, persist: async () => {} });
+ const result = expect(main.prepare()).rejects.toThrow("5 seconds");
+ await vi.advanceTimersByTimeAsync(5000);
+ await result;
+ expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
+ main.dispose();
+ });
+});
diff --git a/apps/desktop/src/lib/app/detachedTabWindow.ts b/apps/desktop/src/lib/app/detachedTabWindow.ts
index 0fb4e1a1ea..de1a0f2131 100644
--- a/apps/desktop/src/lib/app/detachedTabWindow.ts
+++ b/apps/desktop/src/lib/app/detachedTabWindow.ts
@@ -1,3 +1,4 @@
+import { assertUpdateAllowsInteraction, beginUpdateSensitiveOperation } from "@/lib/app/updatePreparation";
import { detachedWindowLabel, detachedWindowUrl } from "@/lib/app/windowContext";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
@@ -19,7 +20,9 @@ function errorMessage(error: unknown): string {
export async function openDetachedTabWindow(tabId: string, title: string, position?: DetachedWindowOpenPosition): Promise {
if (!isTauriRuntime()) return { opened: false, error: "Detached windows are only available in the desktop app." };
+ let finishOperation: (() => void) | undefined;
try {
+ finishOperation = beginUpdateSensitiveOperation();
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const label = detachedWindowLabel(tabId);
const existing = await WebviewWindow.getByLabel(label);
@@ -29,6 +32,7 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
return { opened: true };
}
+ assertUpdateAllowsInteraction();
const child = new WebviewWindow(label, {
url: detachedWindowUrl(tabId),
title,
@@ -42,14 +46,32 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
center: false,
});
+ // A caller timeout does not prove native window creation has stopped.
+ // Keep this second reservation until a native terminal event arrives.
+ const finishNativeCreation = beginUpdateSensitiveOperation();
return await new Promise((resolve) => {
let settled = false;
+ let timedOut = false;
+ let timeout: ReturnType | undefined;
const finish = (result: DetachedWindowOpenResult) => {
if (settled) return;
settled = true;
+ clearTimeout(timeout);
resolve(result);
};
void child.once("tauri://created", async () => {
+ if (timedOut) {
+ try {
+ // The caller already restored its tab; never surface a duplicate.
+ await child.destroy();
+ finishNativeCreation();
+ } catch (error) {
+ // Keep the reservation if removal is uncertain, rather than
+ // allowing restart to race a still-initializing webview.
+ console.error("[DBX][detached-tab:late-create:close-error]", error);
+ }
+ return;
+ }
try {
if (position) {
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
@@ -60,18 +82,26 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
finish({ opened: true });
} catch (error) {
finish({ opened: false, error: errorMessage(error) });
+ } finally {
+ finishNativeCreation();
}
});
void child.once("tauri://error", (event) => {
const error = errorMessage(event?.payload);
console.error("[DBX][detached-tab:create:error]", error);
finish({ opened: false, error });
+ finishNativeCreation();
});
- setTimeout(() => finish({ opened: false, error: "Timed out while creating the detached window." }), 10_000);
+ timeout = setTimeout(() => {
+ timedOut = true;
+ finish({ opened: false, error: "Timed out while creating the detached window." });
+ }, 10_000);
});
} catch (error) {
const message = errorMessage(error);
console.error("[DBX][detached-tab:create:error]", error);
return { opened: false, error: message };
+ } finally {
+ finishOperation?.();
}
}
diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts
index 9bdcde930e..e945bb8da5 100644
--- a/apps/desktop/src/lib/app/openTabsPersistence.ts
+++ b/apps/desktop/src/lib/app/openTabsPersistence.ts
@@ -52,6 +52,7 @@ export interface SavedOpenTab {
nacosNamespace?: string;
nacosNamespaceName?: string;
structureTableName?: string;
+ structureDraft?: QueryTab["structureDraft"];
objectBrowser?: QueryTab["objectBrowser"];
objectSource?: QueryTab["objectSource"];
sourceView?: boolean;
@@ -102,7 +103,7 @@ function shouldPersistTabSql(tab: QueryTab) {
function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) {
if (mode !== "query") return undefined;
if (tab.externalSqlPath) return tab.originalSql ?? sql;
- if (tab.savedSqlId) return sql ? "" : undefined;
+ if (tab.savedSqlId) return tab.originalSql ?? (sql ? "" : undefined);
// Prefer the persisted originalSql so a clean prefilled query tab (sql === originalSql)
// restores clean instead of being marked dirty. Older saved state without this field
// falls through to "" (preserving prior behavior for user-edited scratch tabs).
@@ -141,9 +142,9 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
sql: shouldPersistTabSql(tab) ? tab.sql : "",
...(tab.editorViewport ? { editorViewport: tab.editorViewport } : {}),
...(tab.editorSelection ? { editorSelection: tab.editorSelection } : {}),
- // Plain query tabs always round-trip originalSql. External-file tabs only persist it
- // while dirty so their disk baseline survives restart without duplicating clean SQL.
- ...(tab.originalSql !== undefined && !tab.savedSqlId && (!tab.externalSqlPath || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}),
+ // File-backed drafts retain their baseline, including when the edited SQL is empty.
+ // Clean file-backed tabs omit it so startup can hydrate the current file content.
+ ...(tab.originalSql !== undefined && ((!tab.savedSqlId && !tab.externalSqlPath) || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}),
savedSqlId: tab.savedSqlId,
externalSqlPath: tab.externalSqlPath,
...(tab.externalSqlFileVersion ? { externalSqlFileVersion: tab.externalSqlFileVersion } : {}),
@@ -168,6 +169,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.nacosNamespace !== undefined ? { nacosNamespace: tab.nacosNamespace } : {}),
...(tab.nacosNamespaceName !== undefined ? { nacosNamespaceName: tab.nacosNamespaceName } : {}),
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
+ ...(tab.structureDraft ? { structureDraft: JSON.parse(JSON.stringify(tab.structureDraft)) } : {}),
objectBrowser: tab.objectBrowser,
objectSource: tab.objectSource,
...(tab.sourceView ? { sourceView: true } : {}),
diff --git a/apps/desktop/src/lib/app/updatePreparation.ts b/apps/desktop/src/lib/app/updatePreparation.ts
new file mode 100644
index 0000000000..16d28bfe11
--- /dev/null
+++ b/apps/desktop/src/lib/app/updatePreparation.ts
@@ -0,0 +1,176 @@
+import { onScopeDispose } from "vue";
+import i18n from "@/i18n";
+
+const blockers = new Set<() => string | undefined>();
+let barrierDepth = 0;
+export const UPDATE_RESTORE_KEY = "dbx-update-restore-tabs";
+
+/** Editors with non-durable drafts must explicitly veto a restart. */
+export function useUpdateBlocker(blocker: () => string | undefined): void {
+ blockers.add(blocker);
+ onScopeDispose(() => blockers.delete(blocker));
+}
+
+/** Reserve a window/task transition before its first asynchronous boundary. */
+export function beginUpdateSensitiveOperation(reason?: string): () => void {
+ assertUpdateAllowsInteraction();
+ const blocker = () => reason ?? i18n.global.t("updates.preparationWindowOperation");
+ blockers.add(blocker);
+ return () => {
+ blockers.delete(blocker);
+ };
+}
+
+export function assertUpdateSafe(): void {
+ for (const blocker of blockers) {
+ const reason = blocker();
+ if (reason) throw new Error(reason);
+ }
+}
+
+export function assertUpdateAllowsCommand(command: string): void {
+ if (!barrierDepth) return;
+ // During the short install barrier only updater and durable-draft I/O are allowed.
+ const allowed = new Set(["save_open_tabs_state", "save_detached_tab_handoff", "load_detached_tab_handoff", "save_tab_result_snapshot", "get_downloaded_update", "install_downloaded_update", "discard_downloaded_update"]);
+ if (!allowed.has(command)) throw new Error("Update preparation is in progress. Please wait.");
+}
+
+export function isUpdatePreparationActive(): boolean {
+ return barrierDepth > 0;
+}
+
+export function assertUpdateAllowsInteraction(): void {
+ if (barrierDepth) throw new Error("Update preparation is in progress. Please wait.");
+}
+
+// Register during module evaluation, before App mounts its capture-phase shortcuts.
+// Registering only at installation time would let older listeners run first.
+const inputEvents = ["keydown", "keyup", "pointerdown", "mousedown", "click", "dblclick", "beforeinput", "paste", "cut", "drop", "contextmenu"];
+const stopDuringPreparation = (event: Event) => {
+ if (!barrierDepth) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+};
+if (typeof window !== "undefined" && window.addEventListener) {
+ for (const event of inputEvents) window.addEventListener(event, stopDuringPreparation, true);
+}
+
+export function acquireUpdateBarrier(): () => void {
+ barrierDepth++;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ barrierDepth--;
+ };
+}
+
+/** Mark only fully prepared attempts; retain recovery if install succeeds but relaunch fails. */
+export async function prepareUpdateWithDraftRecovery(prepare: () => Promise<() => void>, storage: Pick = window.localStorage): Promise<() => void> {
+ const release = await prepare();
+ try {
+ storage.setItem(UPDATE_RESTORE_KEY, "1");
+ } catch (error) {
+ release();
+ throw error;
+ }
+ // The release callback does not know whether native installation succeeded.
+ // Conservatively keep drafts recoverable after any fully prepared attempt.
+ return release;
+}
+
+export interface UpdatePreparationParticipant {
+ assertSafe(): void;
+ persist(): Promise;
+ translate?(key: string): string;
+}
+
+/** ACK every live webview before the installer can terminate the process. */
+export async function setupUpdatePreparation(participant: UpdatePreparationParticipant): Promise<{ prepare(): Promise<() => void>; dispose(): void }> {
+ const { listen, emit, emitTo } = await import("@tauri-apps/api/event");
+ const { getAllWebviewWindows, getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
+ const label = getCurrentWebviewWindow().label;
+ const message = (key: string, fallback: string) => participant.translate?.(`updates.${key}`) ?? fallback;
+ const held = new Map void>();
+ const released = new Set();
+ const pending = new Map void>();
+ const prepareLocal = async (id: string) => {
+ if (released.has(id)) throw new Error(message("preparationCancelled", "Update preparation was cancelled"));
+ if (held.has(id)) throw new Error(message("preparationBusy", "Update preparation already in progress"));
+ held.set(id, acquireUpdateBarrier());
+ participant.assertSafe();
+ assertUpdateSafe();
+ await participant.persist();
+ participant.assertSafe();
+ assertUpdateSafe();
+ };
+ const release = (id: string) => {
+ released.add(id);
+ held.get(id)?.();
+ held.delete(id);
+ };
+ const unlisteners = [
+ await listen<{ id: string; owner: string }>("dbx:update-prepare", async ({ payload }) => {
+ if (payload.owner === label) return;
+ let error: string | undefined;
+ try {
+ await prepareLocal(payload.id);
+ } catch (cause) {
+ error = String(cause);
+ release(payload.id);
+ }
+ await emitTo(payload.owner, "dbx:update-prepared", { id: payload.id, label, error });
+ }),
+ await listen<{ id: string; label: string; error?: string }>("dbx:update-prepared", ({ payload }) => pending.get(payload.id)?.(payload)),
+ await listen<{ id: string }>("dbx:update-release", ({ payload }) => release(payload.id)),
+ ];
+ return {
+ async prepare() {
+ if (held.size) throw new Error(message("preparationBusy", "Update preparation already in progress"));
+ const id = crypto.randomUUID();
+ const members = (await getAllWebviewWindows()).map((item) => item.label).sort();
+ const expected = new Set(members.filter((item) => item !== label));
+ let timer: ReturnType | undefined;
+ const acknowledgements = new Promise((resolve, reject) => {
+ pending.set(id, (payload) => {
+ if (!expected.has(payload.label)) return;
+ if (payload.error) {
+ reject(new Error(payload.error));
+ return;
+ }
+ expected.delete(payload.label);
+ if (!expected.size) resolve();
+ });
+ if (!expected.size) resolve();
+ });
+ // Attach the rejection handler immediately while the local disk flush runs.
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(message("preparationTimeout", "A window did not save its drafts within 5 seconds. Please retry."))), 5000);
+ });
+ const work = Promise.race([Promise.all([acknowledgements, prepareLocal(id), emit("dbx:update-prepare", { id, owner: label })]), timeout]);
+ try {
+ await work;
+ const current = (await getAllWebviewWindows()).map((item) => item.label).sort();
+ if (JSON.stringify(current) !== JSON.stringify(members)) throw new Error(message("preparationWindowsChanged", "Open windows changed during update preparation. Please retry."));
+ participant.assertSafe();
+ assertUpdateSafe();
+ return () => {
+ release(id);
+ void emit("dbx:update-release", { id });
+ };
+ } catch (error) {
+ release(id);
+ await emit("dbx:update-release", { id });
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ pending.delete(id);
+ }
+ },
+ dispose() {
+ unlisteners.forEach((unlisten) => unlisten());
+ held.forEach((unlock) => unlock());
+ held.clear();
+ },
+ };
+}
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index 669251f264..355d596f4f 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -833,6 +833,8 @@ export const fetchChangelog = forward("fetchChangelog");
export const getSystemProxyUrl = forward("getSystemProxyUrl");
export const downloadUpdate = forward("downloadUpdate");
export const cancelUpdateDownload = forward("cancelUpdateDownload");
+export const getDownloadedUpdate = forward("getDownloadedUpdate");
+export const discardDownloadedUpdate = forward("discardDownloadedUpdate");
export const installDownloadedUpdate = forward("installDownloadedUpdate");
export const getAppVersion = forward("getAppVersion");
export const getAppSupportInfo = forward("getAppSupportInfo");
@@ -891,6 +893,7 @@ export type {
McpHttpServerStatus,
WebMcpHttpStatus,
UpdateInfo,
+ DownloadedUpdate,
RedisBlob,
RedisCollectionPage,
RedisDatabaseInfo,
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index b2e69be2d4..278b21b322 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -77,6 +77,7 @@ import type {
DriverInstallProgress,
JavaRuntimeConfig,
UpdateInfo,
+ DownloadedUpdate,
UpdateDownloadSource,
RedisCollectionPage,
RedisDatabaseInfo,
@@ -4482,13 +4483,19 @@ export async function getSystemProxyUrl(): Promise {
return null;
}
-export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion?: string): Promise {
+export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion: string, _attemptId: string, _releaseNotes?: string): Promise {
throw new Error("In-app update downloads are only available in the desktop app.");
}
export async function cancelUpdateDownload(): Promise {}
-export async function installDownloadedUpdate(): Promise {
+export async function getDownloadedUpdate(): Promise {
+ return null;
+}
+
+export async function discardDownloadedUpdate(_cacheId: string): Promise {}
+
+export async function installDownloadedUpdate(_cacheId: string, _expectedVersion: string): Promise {
throw new Error("In-app update installation is only available in the desktop app.");
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index fe65cab39e..f9e696df98 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -1,4 +1,10 @@
-import { invoke } from "@tauri-apps/api/core";
+import { invoke as tauriInvoke } from "@tauri-apps/api/core";
+import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation";
+
+function invoke(command: string, args?: Record): Promise {
+ assertUpdateAllowsCommand(command);
+ return tauriInvoke(command, args);
+}
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@@ -2469,7 +2475,18 @@ export interface UpdateInfo {
export type UpdateDownloadSource = "official" | "cnb";
+export interface DownloadedUpdate {
+ cache_id: string;
+ version: string;
+ portable_mode: boolean;
+ release_url: string;
+ release_notes: string;
+ downloaded_at: number;
+}
+
export interface UpdateDownloadProgress {
+ attempt_id: string;
+ version: string;
downloaded: number;
total: number | null;
}
@@ -2516,16 +2533,24 @@ export async function getSystemProxyUrl(): Promise {
return invoke("get_system_proxy_url");
}
-export async function downloadUpdate(source: UpdateDownloadSource, latestVersion?: string): Promise {
- return invoke("download_update", { source, latestVersion });
+export async function downloadUpdate(source: UpdateDownloadSource, latestVersion: string, attemptId: string, releaseNotes?: string): Promise {
+ return invoke("download_update", { source, latestVersion, attemptId, releaseNotes });
}
export async function cancelUpdateDownload(): Promise {
return invoke("cancel_update_download");
}
-export async function installDownloadedUpdate(): Promise {
- return invoke("install_downloaded_update");
+export async function getDownloadedUpdate(): Promise {
+ return invoke("get_downloaded_update");
+}
+
+export async function discardDownloadedUpdate(cacheId: string): Promise {
+ return invoke("discard_downloaded_update", { cacheId });
+}
+
+export async function installDownloadedUpdate(cacheId: string, expectedVersion: string): Promise {
+ return invoke("install_downloaded_update", { cacheId, expectedVersion });
}
export async function getAppVersion(): Promise {
diff --git a/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts
index 1c3d326778..1bac396c9e 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts
@@ -71,15 +71,15 @@ describe("queryStore adoptDetachedTab", () => {
expect(queryStore.isTabDirty(tab)).toBe(false);
});
- it("does not touch the backend when the handoff already carries unsaved edits", async () => {
+ it.each(["SELECT 2;", ""])("does not replace unsaved edits %j with file content", async (sql) => {
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
- const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql: "SELECT 2;", originalSql: "SELECT 1;" }));
+ const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql, originalSql: "SELECT 1;" }));
expect(mocks.loadSavedSqlFile).not.toHaveBeenCalled();
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!;
- expect(tab.sql).toBe("SELECT 2;");
+ expect(tab.sql).toBe(sql);
expect(queryStore.isTabDirty(tab)).toBe(true);
});
diff --git a/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts
new file mode 100644
index 0000000000..be4c33af24
--- /dev/null
+++ b/apps/desktop/src/stores/__tests__/queryStore.updateDrafts.spec.ts
@@ -0,0 +1,96 @@
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+const savedFile = { id: "saved", connectionId: "conn", database: "db", name: "saved.sql", sql: "SELECT 1;", createdAt: "2026-09-10T00:00:00Z", updatedAt: "2026-09-10T00:00:00Z" };
+const mocks = vi.hoisted(() => ({ saved: null as any }));
+vi.mock("@/lib/backend/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ loadOpenTabsState: async () => mocks.saved,
+ saveOpenTabsState: async (payload: unknown) => {
+ mocks.saved = payload;
+ },
+ listDetachedTabHandoffs: async () => [],
+}));
+describe("update draft recovery", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ mocks.saved = null;
+ const storage = new Map();
+ vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) });
+ setActivePinia(createPinia());
+ });
+ it.each(["", "SELECT 2;"])("preserves a saved SQL draft %j through recovery and file hydration", async (sql) => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const { useSettingsStore } = await import("@/stores/settingsStore");
+ const { useSavedSqlStore } = await import("@/stores/savedSqlStore");
+ const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
+ const store = useQueryStore();
+ const id = store.createTab("conn", "db");
+ Object.assign(store.tabs.find((tab) => tab.id === id)!, { savedSqlId: "saved", sql, originalSql: "SELECT 1;" });
+ await store.flushPendingPersist();
+ localStorage.setItem(UPDATE_RESTORE_KEY, "1");
+ setActivePinia(createPinia());
+ useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
+ const ensureFileContent = vi.spyOn(useSavedSqlStore(), "ensureFileContent");
+ const restored = useQueryStore();
+ await restored.initOpenTabs({ validConnectionIds: ["conn"] });
+ await restored.hydrateSavedSqlTabs();
+ const tab = restored.tabs.find((tab) => tab.id === id)!;
+ expect(tab.sql).toBe(sql);
+ expect(tab.originalSql).toBe("SELECT 1;");
+ expect(restored.isTabDirty(tab)).toBe(true);
+ expect(ensureFileContent).not.toHaveBeenCalled();
+ expect(localStorage.getItem(UPDATE_RESTORE_KEY)).toBeNull();
+ expect(restored.openSavedSql(savedFile)).toBe(id);
+ expect(tab.sql).toBe(sql);
+ expect(tab.originalSql).toBe("SELECT 1;");
+ expect(restored.isTabDirty(tab)).toBe(true);
+ ensureFileContent.mockRestore();
+ });
+
+ it("loads file content when reopening a clean restored SQL library tab", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
+ const store = useQueryStore();
+ const id = store.openSavedSql(savedFile);
+ await store.flushPendingPersist();
+ localStorage.setItem(UPDATE_RESTORE_KEY, "1");
+ setActivePinia(createPinia());
+ const restored = useQueryStore();
+ await restored.initOpenTabs({ validConnectionIds: ["conn"] });
+ const tab = restored.tabs.find((tab) => tab.id === id)!;
+ expect(tab.sql).toBe("");
+ expect(tab.originalSql).toBeUndefined();
+ expect(restored.openSavedSql(savedFile)).toBe(id);
+ expect(tab.sql).toBe(savedFile.sql);
+ expect(tab.originalSql).toBe(savedFile.sql);
+ expect(restored.isTabDirty(tab)).toBe(false);
+ });
+
+ it("recovers SQL and structure drafts once even when normal restore and close protection are disabled", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const { useSettingsStore } = await import("@/stores/settingsStore");
+ const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
+ const settings = useSettingsStore();
+ settings.updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
+ const store = useQueryStore();
+ const sqlId = store.createTab("conn", "db");
+ store.updateSql(sqlId, "select unsaved;");
+ const structureId = store.openTableStructure("conn", "db", undefined, "users");
+ const draft = { dirty: true, initialized: true, ddlDraft: "alter table users add column name text" };
+ store.tabs.find((tab) => tab.id === structureId)!.structureDraft = draft as any;
+ await store.flushPendingPersist();
+ localStorage.setItem(UPDATE_RESTORE_KEY, "1");
+ setActivePinia(createPinia());
+ useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
+ const restored = useQueryStore();
+ await restored.initOpenTabs({ validConnectionIds: ["conn"] });
+ expect(restored.tabs.find((tab) => tab.id === sqlId)?.sql).toBe("select unsaved;");
+ expect(restored.tabs.find((tab) => tab.id === structureId)?.structureDraft).toEqual(draft);
+ expect(localStorage.getItem(UPDATE_RESTORE_KEY)).toBeNull();
+ setActivePinia(createPinia());
+ useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none" });
+ const subsequent = useQueryStore();
+ await subsequent.initOpenTabs({ validConnectionIds: ["conn"] });
+ expect(subsequent.tabs).toEqual([]);
+ });
+});
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index cdcd923b36..547718e84c 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -1,3 +1,4 @@
+import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
@@ -741,7 +742,7 @@ function restoreSavedTabsFromPayload(
payload: { tabs?: unknown; activeTabId?: unknown; groups?: unknown; focusedGroupId?: unknown; orientation?: unknown; sizes?: unknown } | null | undefined,
options: { validConnectionIds?: Iterable } = {},
): { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown } {
- const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
+ const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
if (restoreMode === "none") {
return { tabs: [], activeTabId: null, workspace: undefined };
}
@@ -764,7 +765,7 @@ function restoreSavedTabsFromPayload(
}
function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable } = {}): { tabs: QueryTab[]; activeTabId: string | null; workspace?: undefined } {
- const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
+ const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
if (restoreMode === "none") {
return { tabs: [], activeTabId: null, workspace: undefined };
}
@@ -1034,6 +1035,7 @@ export const useQueryStore = defineStore("query", () => {
* the new tab ownerless until a post-flush watcher repaired the groups.
*/
function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string {
+ assertUpdateAllowsInteraction();
const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1;
if (anchorIndex >= 0) {
tabs.value.splice(anchorIndex + 1, 0, tab);
@@ -2215,7 +2217,7 @@ export const useQueryStore = defineStore("query", () => {
if (saved?.tabs && Array.isArray(saved.tabs)) {
const restored = restoreSavedTabsFromPayload(saved, options);
applyRestoredOpenTabs(restored);
- if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
+ if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
// Restore is explicitly disabled, so stale saved payloads should not
// reappear if the user later changes the setting.
clearLegacySavedTabs();
@@ -2223,6 +2225,7 @@ export const useQueryStore = defineStore("query", () => {
}
await recoverDetachedTabsToMain(options);
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
+ safeLocalStorageRemove(UPDATE_RESTORE_KEY);
isOpenTabsLoaded.value = true;
scheduleResultCacheMaintenance();
return;
@@ -2232,7 +2235,7 @@ export const useQueryStore = defineStore("query", () => {
if (legacy.rawTabs || legacy.rawActiveTabId) {
const restored = restoreLegacySavedTabs(options);
applyRestoredOpenTabs(restored);
- if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
+ if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
// Restore is explicitly disabled, so keeping the legacy startup payload
// would resurrect old tabs if the user later changes the setting.
clearLegacySavedTabs();
@@ -2287,6 +2290,7 @@ export const useQueryStore = defineStore("query", () => {
autoCommit: t.autoCommit,
resultAutoSave: t.resultAutoSave,
structureTableName: t.structureTableName,
+ structureDraft: t.structureDraft,
objectBrowser: t.objectBrowser,
objectSource: t.objectSource,
sourceView: t.sourceView,
@@ -2431,7 +2435,7 @@ export const useQueryStore = defineStore("query", () => {
// A freshly created detached window never runs the main-window bootstrap that
// populates savedSqlStore's local file index, so useSavedSqlStore().ensureFileContent()
// can't find the file locally either — go straight to the backend instead.
- if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql) {
+ if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql && restoredTab.originalSql === undefined) {
const file = await api.loadSavedSqlFile(restoredTab.savedSqlId).catch(() => undefined);
if (file) {
restoredTab.title = restoredTab.customTitle ? restoredTab.title : file.name;
@@ -3155,7 +3159,7 @@ export const useQueryStore = defineStore("query", () => {
return !!tab.structureDraft && tab.structureDraft.dirty !== false;
}
if (tab.mode !== "query") return false;
- if (!tab.externalSqlPath && !tab.sql.trim()) return false;
+ if (!tab.externalSqlPath && !tab.sql.trim() && !(tab.savedSqlId && tab.originalSql !== undefined)) return false;
const original = tab.originalSql;
if (original === undefined) return !!tab.savedSqlId;
return tab.sql !== original;
@@ -4277,7 +4281,7 @@ export const useQueryStore = defineStore("query", () => {
const existing = tabs.value.find((tab) => tab.savedSqlId === file.id);
if (existing) {
persistSavedSqlEditorPosition(existing);
- if (!existing.sql && file.sql) {
+ if (!existing.sql && file.sql && existing.originalSql === undefined) {
existing.sql = file.sql;
existing.originalSql = file.sql;
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
@@ -4317,7 +4321,7 @@ export const useQueryStore = defineStore("query", () => {
async function hydrateSavedSqlTabs() {
await initSavedSqlEditorPositions();
const savedSqlStore = useSavedSqlStore();
- const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "");
+ const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "" && tab.originalSql === undefined);
for (const tab of linkedTabs) {
const file = await savedSqlStore.ensureFileContent(tab.savedSqlId!);
if (!file) continue;
@@ -4602,6 +4606,7 @@ export const useQueryStore = defineStore("query", () => {
}
async function executeCurrentTab() {
+ assertUpdateAllowsInteraction();
const tab = tabs.value.find((t) => t.id === activeTabId.value);
if (!tab || !tab.sql.trim()) return;
@@ -5525,6 +5530,7 @@ export const useQueryStore = defineStore("query", () => {
batchResume?: BatchSqlResumeOptions;
},
) {
+ assertUpdateAllowsInteraction();
const tab = findExecutionTab(id);
if (!tab || !sql.trim()) return;
@@ -6780,6 +6786,7 @@ export const useQueryStore = defineStore("query", () => {
}
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
+ assertUpdateAllowsInteraction();
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return { ok: false as const, reason: "empty" as const };
const conn = useConnectionStore().getConfig(tab.connectionId);
diff --git a/docs/testing/software-update.md b/docs/testing/software-update.md
new file mode 100644
index 0000000000..28abb58e12
--- /dev/null
+++ b/docs/testing/software-update.md
@@ -0,0 +1,52 @@
+# 桌面更新验收
+
+本清单用于打包后的真实升级测试。单元测试不能证明系统安装器能够替换文件、重新启动应用或恢复窗口内容。使用测试签名与独立测试配置发布两个连续版本,避免给正式用户推送测试版本。
+
+## 环境
+
+- macOS:从安装到 Applications 的旧版应用升级。
+- Windows:当前用户安装的 NSIS 版本,以及带 `portable.dbx` 的便携版本;发布 MSI 时追加 MSI 验收。
+- 两种 CPU 架构分别验证实际发布的安装产物;不支持自动更新的 Windows 7 包继续手动升级。
+- 升级前记录应用版本、用户数据目录、连接列表、SQL 草稿和窗口布局;使用可丢弃的测试数据库。
+
+## 静默下载与就绪
+
+1. 开启更新通知,启动旧版本,发布或提供新版本元数据。
+2. 不操作更新入口:确认没有通知、弹窗、工具栏下载动画,也没有安装器启动或应用退出。
+3. 主动打开更新面板,确认可见下载进度;关闭面板,下载继续。
+4. 包完成下载、验签和缓存提交后才出现红点,面板显示准确版本与“重启并更新”。
+5. 不点击安装,等待任务完成、关闭面板、正常退出应用,均不得开始安装。
+
+## 本地缓存与失败恢复
+
+1. 下载就绪后退出应用、断网、重新启动:应恢复同一版本的就绪状态。
+2. 点击“重启并更新”,确认安装过程不请求更新元数据或重新下载安装包。
+3. 分别截断安装包、修改签名、放入未完成缓存、使用不匹配架构或已安装版本:不能显示为可安装,不能启动安装器。
+4. 模拟网络断开、下载超时、磁盘满:后台无提示;主动打开面板可查看原因、重试或切源。
+5. 下载中取消或切源,确认旧批次的进度/完成事件不覆盖新批次;缓存已经就绪时切源不重下。
+6. 忽略就绪版本后,重开应用不再提示或下载该版本;关闭更新通知停止未完成下载,但保留完整缓存供手动安装。
+
+## 工作与草稿保护
+
+1. 分别保留新 SQL、已保存 SQL 的修改、外部 SQL 文件修改和可恢复的结构草稿;普通退出设置切换到不保留草稿,仍应在更新后恢复本次编辑。
+2. 在分离窗口修改 SQL;更新前必须收到该窗口的持久化成功确认,升级后核对实际内容。
+3. 模拟保存失败或分离窗口无响应超过 5 秒:安装不执行,已有缓存不丢失,界面恢复操作能力。
+4. 保留未提交网格修改、活动事务、无法恢复的配置编辑:更新被阻止,不得自动提交或丢弃内容。
+5. 查询、导入导出或 AI 任务运行时不能安装;任务结束后仍需主动点击。
+6. 在准备保存期间尝试启动任务、编辑或打开新分离窗口:不能漏过保护;准备失败后这些操作应恢复正常。
+
+## 安装与重启
+
+1. macOS:点击一次后安装并立即重启;不再要求第二次点击。模拟重启失败后,再次操作只重启、不重复安装。
+2. Windows 安装版:只在明确点击后启动一次安装器并退出应用;安装成功后自动启动新版。
+3. Windows:取消系统权限请求或模拟安装器启动失败,应用不能直接退出或永久停在安装中;允许重试。
+4. Windows 便携版:替换并重启后继续使用原用户数据目录;模拟替换/启动失败,检查原程序回退。
+5. 重启后确认版本、草稿、连接列表与用户数据正确;旧缓存已清理。
+
+## 记录
+
+每次验收记录旧版/新版号、系统与架构、安装类型、结果以及失败日志。只有实际执行通过的项目才能标记通过;尚未执行的平台明确标记“未验证”。
+
+## 当前保护边界
+
+更新准备屏障覆盖主窗口、分离窗口及桌面 API 调用,不是数据库引擎的全局任务锁。独立 MCP 客户端或原生后端直接发起的工作不受这一前端屏障原子控制。没有可靠编辑基线的配置对话框会保守地阻止更新,关闭该对话框后才能继续。
diff --git a/packages/app-tests/appUpdater.test.ts b/packages/app-tests/appUpdater.test.ts
index 5dfe5734a3..9916e34196 100644
--- a/packages/app-tests/appUpdater.test.ts
+++ b/packages/app-tests/appUpdater.test.ts
@@ -122,9 +122,9 @@ test("wires the active task guard into update installation and restart, but not
assert.match(appSource, /getActiveTaskCount: \(\) => trackedUpdateTaskCount\.value/);
// Only restarting/exiting to finish the install has to wait on active work;
// starting the background download itself never blocks.
- assert.equal(updaterSource.match(/if \(blockUpdateForActiveTasks\(\)\) return;/g)?.length, 1);
+ assert.match(updaterSource, /shouldBlockAppUpdate\(activeTaskCount.value\)/);
assert.match(dialogSource, /role="alert"[\s\S]*updates\.activeTasksBlockUpdate/);
- assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0"/g)?.length, 2);
+ assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0 \|\| isIgnoringUpdate"/g)?.length, 2);
assert.match(dialogSource, /ignore-version/);
assert.match(updaterSource, /ignoreCurrentVersion/);
});
diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts
index 908e0540dd..dadcf2b1e5 100644
--- a/packages/app-tests/queryStore.test.ts
+++ b/packages/app-tests/queryStore.test.ts
@@ -515,7 +515,9 @@ test("hydrating saved SQL content preserves its restored runtime target", async
store.createTab("current-connection", "runtime_database", "current.sql", "query", "runtime_schema");
const tabId = store.openSavedSql(file);
const tab = store.tabs.find((item) => item.id === tabId)!;
+ // Clean restored tabs omit both fields until their saved file is hydrated.
tab.sql = "";
+ tab.originalSql = undefined;
await store.hydrateSavedSqlTabs();
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3a8f742ceb..33b77b4cd2 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -46,7 +46,7 @@ tauri-plugin-dialog = "2.7.0"
tauri-plugin-fs = "2.5.0"
tauri-plugin-single-instance = "2.4.2"
tauri-plugin-shell = "2"
-tauri-plugin-updater = "2"
+tauri-plugin-updater = "=2.10.1"
tauri-plugin-process = "2"
tauri-plugin-window-state = "2"
redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp", "cluster-async"] }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index e8c21bf16f..64c5c60b6e 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -16,6 +16,7 @@
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-close",
+ "core:window:allow-destroy",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-set-position",
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 3e6a919ab5..da25644c7b 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -58,6 +58,7 @@ pub mod text_export;
pub mod transfer;
pub mod tunnel_profiles;
pub mod update;
+mod update_cache;
mod update_portable;
pub mod vector_cmd;
pub mod window_controls;
diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs
index 3c7b9cce40..e5b3704e9f 100644
--- a/src-tauri/src/commands/update.rs
+++ b/src-tauri/src/commands/update.rs
@@ -5,7 +5,11 @@ use std::sync::{
};
use std::time::Duration;
-use super::update_portable;
+pub use super::update_cache::DownloadedUpdate;
+use super::{
+ update_cache::{self, CacheRecord},
+ update_portable,
+};
pub use dbx_core::update::UpdateInfo;
use semver::Version;
use serde::{Deserialize, Serialize};
@@ -37,6 +41,8 @@ pub enum UpdateDownloadSource {
pub struct UpdateDownloadProgress {
pub downloaded: u64,
pub total: Option,
+ pub attempt_id: String,
+ pub version: String,
}
#[derive(Default)]
@@ -64,7 +70,12 @@ impl UpdateDownloadProgressGate {
enum PendingUpdate {
Downloading(Arc),
Installing,
- Ready(ReadyUpdate),
+ Ready(Box),
+}
+
+struct CachedUpdate {
+ record: CacheRecord,
+ ready: ReadyUpdate,
}
enum ReadyUpdate {
@@ -128,7 +139,12 @@ impl PendingUpdateState {
Ok(cancellation)
}
- fn finish_download(&self, cancellation: &Arc, update: ReadyUpdate) -> Result<(), String> {
+ fn finish_download(
+ &self,
+ cancellation: &Arc,
+ update: CachedUpdate,
+ root: &std::path::Path,
+ ) -> Result<(), String> {
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
let is_current = matches!(
pending.as_ref(),
@@ -138,7 +154,12 @@ impl PendingUpdateState {
if !is_current {
return Err(DOWNLOAD_CANCELED_ERROR.to_string());
}
- *pending = Some(PendingUpdate::Ready(update));
+ let bytes = match &update.ready {
+ ReadyUpdate::Installer { bytes, .. } => bytes,
+ ReadyUpdate::Portable { archive, .. } => archive,
+ };
+ update_cache::commit(root, &update.record, bytes)?;
+ *pending = Some(PendingUpdate::Ready(Box::new(update)));
Ok(())
}
@@ -163,12 +184,14 @@ impl PendingUpdateState {
}
}
- fn take_ready(&self) -> Result {
+ fn take_ready(&self, cache_id: &str, expected_version: &str) -> Result {
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
match pending.take() {
- Some(PendingUpdate::Ready(update)) => {
+ Some(PendingUpdate::Ready(update))
+ if update.record.info.cache_id == cache_id && update.record.info.version == expected_version =>
+ {
*pending = Some(PendingUpdate::Installing);
- Ok(update)
+ Ok(*update)
}
other => {
*pending = other;
@@ -177,12 +200,24 @@ impl PendingUpdateState {
}
}
- fn restore_ready(&self, update: ReadyUpdate) -> Result<(), String> {
+ fn restore_ready(&self, update: CachedUpdate) -> Result<(), String> {
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
- *pending = Some(PendingUpdate::Ready(update));
+ *pending = Some(PendingUpdate::Ready(Box::new(update)));
Ok(())
}
+ fn discard_ready(&self, cache_id: &str, root: &std::path::Path) -> Result<(), String> {
+ let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.")?;
+ match pending.as_ref() {
+ Some(PendingUpdate::Ready(cached)) if cached.record.info.cache_id == cache_id => {}
+ _ => return Err("Cached update changed or is busy.".into()),
+ }
+ // Logical eviction must succeed even if disk cleanup fails. A later download
+ // retries cleanup before any network work or cache commit.
+ *pending = None;
+ update_cache::discard(root)
+ }
+
fn finish_install(&self) -> Result<(), String> {
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
*pending = None;
@@ -351,47 +386,175 @@ pub fn cancel_update_download(state: tauri::State<'_, PendingUpdateState>) {
state.cancel_download();
}
+fn package_kind(app: &AppHandle) -> Result {
+ if crate::data_dir::is_portable_mode() {
+ return Ok("portable".into());
+ }
+ Ok(app.updater_builder().build().map_err(|e| e.to_string())?.package_kind().into())
+}
+
+fn validate_cache_target(record: &CacheRecord, portable_mode: bool, package_kind: &str) -> Result<(), String> {
+ if record.package_kind != package_kind
+ || record.os != std::env::consts::OS
+ || record.arch != std::env::consts::ARCH
+ || record.info.portable_mode != portable_mode
+ {
+ return Err("Cached update does not match this installation.".into());
+ }
+ Ok(())
+}
+
+fn restore_cached(app: &AppHandle) -> Result