feat(updater): download and prepare desktop updates in background
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user