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

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

No files matched your search

@@ -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<string, (event?: unknown) => 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();
});
});
@@ -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<string, Set<{ label: string; callback: (event: any) => 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<string, string>();
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();
});
});
+31 -1
View File
@@ -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<DetachedWindowOpenResult> {
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<typeof setTimeout> | 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?.();
}
}
@@ -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 } : {}),
@@ -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<Storage, "setItem"> = 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<void>;
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<string, () => void>();
const released = new Set<string>();
const pending = new Map<string, (payload: { label: string; error?: string }) => 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<typeof setTimeout> | undefined;
const acknowledgements = new Promise<void>((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<never>((_, 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();
},
};
}