feat(transfer): persist reusable transfer tasks

This commit is contained in:
lizzzzz777 authored and GitHub committed 2026-08-14 18:25:05 +08:00
1 parent 1a10db4c18
commit 05361eb40d
26 files changed
+2628 -241

No files matched your search

@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from "vitest";
function installIndexedDb(transactionOutcome: "complete" | "abort") {
const transaction: Record<string, any> = {
error: null,
oncomplete: null,
onabort: null,
onerror: null,
};
const store = {
put(_value: unknown, key: IDBValidKey) {
const request: Record<string, any> = { result: undefined, error: null, onsuccess: null, onerror: null };
queueMicrotask(() => {
request.result = key;
request.onsuccess?.();
queueMicrotask(() => {
if (transactionOutcome === "complete") {
transaction.oncomplete?.();
} else {
transaction.error = new Error("transaction aborted");
transaction.onabort?.();
}
});
});
return request as IDBRequest<IDBValidKey>;
},
};
transaction.objectStore = () => store;
const database = {
transaction: () => transaction as IDBTransaction,
};
const indexedDB = {
open() {
const request: Record<string, any> = { result: database, error: null, onsuccess: null, onerror: null, onblocked: null, onupgradeneeded: null };
queueMicrotask(() => request.onsuccess?.());
return request as IDBOpenDBRequest;
},
};
vi.stubGlobal("indexedDB", indexedDB);
}
function installLocalStorage(setItem: (key: string, value: string) => void) {
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(setItem),
removeItem: vi.fn(),
});
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
describe("saveBrowserAppState", () => {
it("waits for transaction commit and falls back after an IndexedDB abort", async () => {
installIndexedDb("abort");
installLocalStorage(() => undefined);
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).resolves.toBeUndefined();
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith("dbx-app-state:transfer_task_library", JSON.stringify({ version: 1 }));
});
it("throws when localStorage quota prevents the fallback write", async () => {
vi.stubGlobal("indexedDB", undefined);
installLocalStorage(() => {
throw new Error("quota exceeded");
});
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).rejects.toThrow("Failed to persist browser app state: transfer_task_library");
});
it("throws when both IndexedDB and localStorage fail", async () => {
installIndexedDb("abort");
installLocalStorage(() => {
throw new Error("storage disabled");
});
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const { saveBrowserAppState } = await import("@/lib/backend/browserAppStateStorage");
await expect(saveBrowserAppState("transfer_task_library", { version: 1 })).rejects.toThrow("Failed to persist browser app state: transfer_task_library");
});
});
+2
View File
@@ -300,6 +300,8 @@ export const loadOpenTabsState = forward("loadOpenTabsState");
export const saveOpenTabsState = forward("saveOpenTabsState");
export const loadSavedSqlEditorPositions = forward("loadSavedSqlEditorPositions");
export const saveSavedSqlEditorPositions = forward("saveSavedSqlEditorPositions");
export const loadTransferTaskLibrary = forward("loadTransferTaskLibrary");
export const saveTransferTaskLibrary = forward("saveTransferTaskLibrary");
export const webdavSyncTest = forward("webdavSyncTest");
export const webdavPasswordStatus = forward("webdavPasswordStatus");
export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword");
@@ -16,6 +16,14 @@ function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
});
}
function transactionToPromise(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed"));
});
}
let dbPromise: Promise<IDBDatabase | null> | undefined;
function openDb(): Promise<IDBDatabase | null> {
@@ -44,7 +52,13 @@ async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStor
const db = await openDb();
if (!db) return null;
try {
return await requestToPromise(run(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME)));
const transaction = db.transaction(STORE_NAME, mode);
const request = requestToPromise(run(transaction.objectStore(STORE_NAME)));
if (mode === "readwrite") {
const [result] = await Promise.all([request, transactionToPromise(transaction)]);
return result;
}
return await request;
} catch {
return null;
}
@@ -66,5 +80,6 @@ export async function loadBrowserAppState(key: string): Promise<unknown | null>
export async function saveBrowserAppState(key: string, value: unknown): Promise<void> {
const result = await withStore("readwrite", (store) => store.put(value, key));
if (result !== null) return;
safeLocalStorageSet(fallbackKey(key), JSON.stringify(value));
if (safeLocalStorageSet(fallbackKey(key), JSON.stringify(value))) return;
throw new Error(`Failed to persist browser app state: ${key}`);
}
+8
View File
@@ -1678,6 +1678,14 @@ export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise
await saveBrowserAppState("saved_sql_editor_positions", positions);
}
export async function loadTransferTaskLibrary(): Promise<unknown | null> {
return loadBrowserAppState("transfer_task_library");
}
export async function saveTransferTaskLibrary(library: unknown): Promise<void> {
await saveBrowserAppState("transfer_task_library", library);
}
export async function completeAppClose(_action: "quit" | "hide"): Promise<void> {
return undefined;
}
+6 -2
View File
@@ -11,11 +11,15 @@ export function safeLocalStorageGet(key: string): string | null {
}
}
export function safeLocalStorageSet(key: string, value: string) {
export function safeLocalStorageSet(key: string, value: string): boolean {
try {
globalThis.localStorage?.setItem(key, value);
const storage = globalThis.localStorage;
if (!storage) return false;
storage.setItem(key, value);
return true;
} catch (error) {
storageWarn("set", key, error);
return false;
}
}
+8
View File
@@ -613,6 +613,14 @@ export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise
return invoke("save_saved_sql_editor_positions", { positions });
}
export async function loadTransferTaskLibrary(): Promise<unknown | null> {
return invoke("load_transfer_task_library");
}
export async function saveTransferTaskLibrary(library: unknown): Promise<void> {
return invoke("save_transfer_task_library", { library });
}
export async function completeAppClose(action: "quit" | "hide"): Promise<void> {
return invoke("complete_app_close", { action });
}