fix(StorageCleaner): 修复 IndexedDB 清理竞态、部分成功计数与刷新后状态同步

This commit is contained in:
2026-06-25 23:31:44 +08:00
parent ebf9f7a153
commit edd4cf266c
13 changed files with 1545 additions and 128 deletions
@@ -0,0 +1,489 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearAllIndexedDBs,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
INDEXED_DB_DELETE_TIMEOUT_MS,
} from '@/utils/indexedDbCleaner';
type DeleteDatabaseBehavior = 'success' | 'blocked' | 'timeout' | 'error';
type ClearStoreBehavior = 'success' | 'hang';
function createDeleteDatabaseMock(behavior: DeleteDatabaseBehavior) {
return vi.fn(() => {
const request = {} as IDBOpenDBRequest;
if (behavior === 'timeout') {
return request;
}
queueMicrotask(() => {
if (behavior === 'blocked') {
request.onblocked?.({} as IDBVersionChangeEvent);
} else if (behavior === 'success') {
request.onsuccess?.({} as Event);
} else if (behavior === 'error') {
request.onerror?.({} as Event);
}
});
return request;
});
}
function createDeleteDatabaseMockWithLateSuccess(lateAfterMs: number) {
return vi.fn(() => {
const request = {} as IDBOpenDBRequest;
setTimeout(() => {
request.onsuccess?.({} as Event);
}, lateAfterMs);
return request;
});
}
function createOpenMock(options: {
storeNames: string[];
onClearStore?: () => void;
onTransactionComplete?: () => void;
onDbClose?: () => void;
clearStoreBehavior?: ClearStoreBehavior;
deferTransactionComplete?: boolean;
syncTransactionComplete?: boolean;
hangOpen?: boolean;
}) {
const {
storeNames,
onClearStore,
onTransactionComplete,
onDbClose,
clearStoreBehavior = 'success',
deferTransactionComplete = false,
syncTransactionComplete = false,
hangOpen = false,
} = options;
return vi.fn(() => {
const request = {} as IDBOpenDBRequest;
if (hangOpen) {
return request;
}
const db = {
objectStoreNames: storeNames,
transaction: vi.fn(() => {
const tx = {
oncomplete: null as ((event: Event) => void) | null,
onerror: null as ((event: Event) => void) | null,
onabort: null as ((event: Event) => void) | null,
abort: vi.fn(),
objectStore: vi.fn(() => ({
clear: () => {
const clearRequest = {} as IDBRequest<void>;
onClearStore?.();
if (clearStoreBehavior === 'success') {
queueMicrotask(() => {
clearRequest.onsuccess?.({} as Event);
if (syncTransactionComplete) {
onTransactionComplete?.();
tx.oncomplete?.({} as Event);
return;
}
if (deferTransactionComplete) {
return;
}
setTimeout(() => {
onTransactionComplete?.();
tx.oncomplete?.({} as Event);
}, 0);
});
}
return clearRequest;
},
})),
};
return tx;
}),
close: vi.fn(() => {
onDbClose?.();
}),
};
queueMicrotask(() => {
Object.defineProperty(request, 'result', { value: db });
request.onsuccess?.({} as Event);
});
return request;
});
}
type DeleteBehaviorConfig = DeleteDatabaseBehavior | Record<string, DeleteDatabaseBehavior>;
function createIndexedDBMock(options: {
databases: Array<{ name: string }>;
deleteBehavior: DeleteBehaviorConfig;
open?: ReturnType<typeof createOpenMock>;
}) {
const resolveDeleteBehavior = (dbName: string): DeleteDatabaseBehavior => {
if (typeof options.deleteBehavior === 'string') {
return options.deleteBehavior;
}
return options.deleteBehavior[dbName] ?? 'success';
};
return {
databases: vi.fn().mockResolvedValue(options.databases),
deleteDatabase: vi.fn((dbName: string) =>
createDeleteDatabaseMock(resolveDeleteBehavior(dbName))(),
),
...(options.open ? { open: options.open } : {}),
};
}
async function withIndexedDBMock<T>(indexedDBMock: object, run: () => Promise<T>): Promise<T> {
const originalIndexedDB = globalThis.indexedDB;
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: indexedDBMock });
try {
return await run();
} finally {
Object.defineProperty(globalThis, 'indexedDB', {
configurable: true,
value: originalIndexedDB,
});
}
}
describe('indexedDbCleaner', () => {
const runClear = () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
);
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('should delete IndexedDB successfully when deleteDatabase completes', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'app-db' }],
deleteBehavior: 'success',
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({ count: 1, errors: [] });
expect(indexedDBMock.deleteDatabase).toHaveBeenCalledWith('app-db');
});
it('should report IndexedDB blocked deletions with a user-facing hint', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'blocked-db' }],
deleteBehavior: 'blocked',
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({
count: 0,
errors: ['页面仍占用 IndexedDBblocked-db),请刷新后重试或关闭占用该页面的连接'],
});
expect(indexedDBMock.deleteDatabase).toHaveBeenCalledWith('blocked-db');
});
it('should clear object stores when IndexedDB deletion is blocked', async () => {
const clearStore = vi.fn();
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'blocked',
open: createOpenMock({ storeNames: ['images'], onClearStore: clearStore }),
});
const result = await withIndexedDBMock(indexedDBMock, runClear);
expect(result).toEqual({ count: 1, errors: [] });
expect(clearStore).toHaveBeenCalledTimes(1);
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
});
it('should clear object stores when IndexedDB deletion times out', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const clearStore = vi.fn();
const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore });
const hangOpenRequest = {} as IDBOpenDBRequest;
openMock.mockImplementationOnce(() => hangOpenRequest);
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'timeout',
open: openMock,
});
const result = await withIndexedDBMock(indexedDBMock, async () => {
const resultPromise = runClear();
await vi.advanceTimersByTimeAsync(
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
INDEXED_DB_DELETE_TIMEOUT_MS +
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
);
await vi.runOnlyPendingTimersAsync();
return resultPromise;
});
expect(result).toEqual({ count: 1, errors: [] });
expect(clearStore).toHaveBeenCalledTimes(1);
});
it('should wait for transaction complete before closing db', async () => {
const events: string[] = [];
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'blocked',
open: createOpenMock({
storeNames: ['images'],
onTransactionComplete: () => events.push('transaction-complete'),
onDbClose: () => events.push('db-close'),
}),
});
await withIndexedDBMock(indexedDBMock, runClear);
expect(events).toEqual(['transaction-complete', 'db-close']);
});
it('should not hang when transaction completes before clear promises settle', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'blocked',
open: createOpenMock({
storeNames: ['images'],
syncTransactionComplete: true,
}),
});
const result = await withIndexedDBMock(indexedDBMock, runClear);
expect(result).toEqual({ count: 1, errors: [] });
});
it('should timeout when opening IndexedDB for fallback never completes', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'blocked',
open: createOpenMock({ storeNames: ['images'], hangOpen: true }),
});
const resultPromise = withIndexedDBMock(indexedDBMock, runClear);
await vi.advanceTimersByTimeAsync(
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
INDEXED_DB_DELETE_FALLBACK_DELAY_MS +
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
);
const result = await resultPromise;
expect(result).toEqual({
count: 0,
errors: ['无法打开 IndexedDBImageCacheDB)进行清空,请刷新后重试'],
});
});
it('should ignore late onsuccess after delete timeout', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const clearStore = vi.fn();
const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore });
const hangOpenRequest = {} as IDBOpenDBRequest;
openMock.mockImplementationOnce(() => hangOpenRequest);
const indexedDBMock = {
databases: vi.fn().mockResolvedValue([{ name: 'ImageCacheDB' }]),
deleteDatabase: createDeleteDatabaseMockWithLateSuccess(INDEXED_DB_DELETE_TIMEOUT_MS + 1000),
open: openMock,
};
const result = await withIndexedDBMock(indexedDBMock, async () => {
const resultPromise = runClear();
await vi.advanceTimersByTimeAsync(
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
INDEXED_DB_DELETE_TIMEOUT_MS +
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
);
await vi.runOnlyPendingTimersAsync();
return resultPromise;
});
expect(result).toEqual({ count: 1, errors: [] });
expect(clearStore).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
expect(result).toEqual({ count: 1, errors: [] });
});
it('should report the store name when fallback clearing times out', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const openMock = createOpenMock({ storeNames: ['images'], clearStoreBehavior: 'hang' });
const hangOpenRequest = {} as IDBOpenDBRequest;
openMock.mockImplementationOnce(() => hangOpenRequest);
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'blocked',
open: openMock,
});
const result = await withIndexedDBMock(indexedDBMock, async () => {
const resultPromise = runClear();
await vi.advanceTimersByTimeAsync(
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS +
INDEXED_DB_DELETE_FALLBACK_DELAY_MS +
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
);
return resultPromise;
});
expect(result).toEqual({
count: 0,
errors: ['清空 IndexedDB 超时(ImageCacheDB/images),请刷新后重试'],
});
});
it('should delete all IndexedDB databases when every delete succeeds', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }],
deleteBehavior: 'success',
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({ count: 3, errors: [] });
expect(indexedDBMock.deleteDatabase).toHaveBeenCalledTimes(3);
});
it('should succeed when some deletions fallback to clear object stores', async () => {
const clearStore = vi.fn();
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }],
deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'db-c': 'blocked' },
open: createOpenMock({ storeNames: ['data'], onClearStore: clearStore }),
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({ count: 3, errors: [] });
expect(clearStore).toHaveBeenCalledTimes(3);
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
});
it('should preserve partial count when some IndexedDB databases fail', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }],
deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'db-c': 'error' },
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({
count: 2,
errors: ['删除 IndexedDB 失败(db-c),请刷新后重试'],
});
});
it('should preserve partial count when blocked fallback fails for one database', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'blocked-db' }],
deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'blocked-db': 'blocked' },
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({
count: 2,
errors: ['页面仍占用 IndexedDBblocked-db),请刷新后重试或关闭占用该页面的连接'],
});
});
it('should clear ImageCacheDB on consecutive attempts without calling delete first', async () => {
const clearStore = vi.fn();
const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore });
const indexedDBMock = createIndexedDBMock({
databases: [{ name: 'ImageCacheDB' }],
deleteBehavior: 'blocked',
open: openMock,
});
await withIndexedDBMock(indexedDBMock, runClear);
const secondResult = await withIndexedDBMock(indexedDBMock, runClear);
expect(secondResult).toEqual({ count: 1, errors: [] });
expect(clearStore).toHaveBeenCalledTimes(2);
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
});
it('should run when deserialized into page context with explicit timeout args', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [],
deleteBehavior: 'success',
});
const result = await withIndexedDBMock(indexedDBMock, async () => {
const injected = (0, eval)(`(${clearAllIndexedDBs.toString()})`) as typeof clearAllIndexedDBs;
return injected(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
);
});
expect(result).toEqual({ count: 0, errors: [] });
});
it('should return count 0 when no IndexedDB databases exist', async () => {
const indexedDBMock = createIndexedDBMock({
databases: [],
deleteBehavior: 'success',
});
const result = await withIndexedDBMock(indexedDBMock, () =>
clearAllIndexedDBs(
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
),
);
expect(result).toEqual({ count: 0, errors: [] });
expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled();
});
});
+158 -33
View File
@@ -1,39 +1,61 @@
import { describe, expect, it } from 'vitest';
import { clearCookies } from '@/utils/storageCleaner';
import { formatBytes } from '@/utils/format';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearAllIndexedDBs,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
INDEXED_DB_DELETE_TIMEOUT_MS,
} from '@/utils/indexedDbCleaner';
import { clearCookies, clearStorage } from '@/utils/storageCleaner';
import type { StorageCleanerOptions } from '@/types/storage';
const indexedDBClearOptions: StorageCleanerOptions = {
localStorage: false,
sessionStorage: false,
indexedDB: true,
cookies: false,
cacheStorage: false,
serviceWorkers: false,
};
function mockExecuteScriptEval() {
(chrome.scripting.executeScript as any).mockImplementationOnce(
async ({ func, args }: { func: (...a: unknown[]) => unknown; args?: unknown[] }) => {
if (func === clearAllIndexedDBs) {
const deleteTimeoutMs = (args?.[0] as number | undefined) ?? INDEXED_DB_DELETE_TIMEOUT_MS;
const clearStoreTimeoutMs =
(args?.[1] as number | undefined) ?? INDEXED_DB_CLEAR_STORE_TIMEOUT_MS;
const fallbackDelayMs =
(args?.[2] as number | undefined) ?? INDEXED_DB_DELETE_FALLBACK_DELAY_MS;
return [
{
result: await clearAllIndexedDBs(deleteTimeoutMs, clearStoreTimeoutMs, fallbackDelayMs),
},
];
}
const isolatedFunc = (0, eval)(`(${func.toString()})`) as (
...a: unknown[]
) => Promise<unknown>;
return [{ result: await isolatedFunc(...(args ?? [])) }];
},
);
}
function mockIndexedDBForEmptyDatabases() {
Object.defineProperty(globalThis, 'indexedDB', {
configurable: true,
value: {
databases: vi.fn().mockResolvedValue([]),
},
});
}
describe('storageCleaner utils', () => {
describe('formatBytes', () => {
it('should return "0 B" for 0 bytes', () => {
expect(formatBytes(0)).toBe('0 B');
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should format bytes correctly', () => {
expect(formatBytes(500)).toBe('500 B');
});
it('should format kilobytes correctly', () => {
expect(formatBytes(1024)).toBe('1.0 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(2048)).toBe('2.0 KB');
});
it('should format megabytes correctly', () => {
expect(formatBytes(1048576)).toBe('1.00 MB');
expect(formatBytes(1572864)).toBe('1.50 MB');
expect(formatBytes(5242880)).toBe('5.00 MB');
});
it('should format gigabytes correctly', () => {
expect(formatBytes(1073741824)).toBe('1.00 GB');
expect(formatBytes(2147483648)).toBe('2.00 GB');
});
it('should handle edge cases', () => {
expect(formatBytes(1)).toBe('1 B');
expect(formatBytes(1023)).toBe('1023 B');
expect(formatBytes(1025)).toBe('1.0 KB');
});
afterEach(() => {
vi.useRealTimers();
});
describe('clearCookies', () => {
@@ -85,4 +107,107 @@ describe('storageCleaner utils', () => {
expect(result).toEqual({ success: false, error: 'Error: Permission denied' });
});
});
describe('clearStorage', () => {
it('should report script injection failures instead of treating fallback values as success', async () => {
(chrome.scripting.executeScript as any).mockRejectedValueOnce(
new Error('Cannot access this page'),
);
const result = await clearStorage(1, 'https://example.com', {
localStorage: true,
sessionStorage: false,
indexedDB: false,
cookies: false,
cacheStorage: false,
serviceWorkers: false,
});
expect(result.overallSuccess).toBe(false);
expect(result.localStorage).toEqual({
success: false,
error: 'Error: Cannot access this page',
});
});
it('should wire IndexedDB cleanup through executeScript with clearAllIndexedDBs', async () => {
mockIndexedDBForEmptyDatabases();
mockExecuteScriptEval();
const result = await clearStorage(1, 'https://example.com', indexedDBClearOptions);
expect(chrome.scripting.executeScript).toHaveBeenCalledWith(
expect.objectContaining({
func: clearAllIndexedDBs,
args: [
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
],
}),
);
expect(result.overallSuccess).toBe(true);
expect(result.indexedDB).toEqual({ success: true, count: 0 });
});
it('should aggregate selected storage failures into overallSuccess', async () => {
(chrome.scripting.executeScript as any).mockResolvedValueOnce([{ result: { count: 1 } }]);
(chrome.cookies.getAll as any).mockRejectedValueOnce(new Error('Cookie denied'));
const result = await clearStorage(1, 'https://example.com', {
localStorage: true,
sessionStorage: false,
indexedDB: false,
cookies: true,
cacheStorage: false,
serviceWorkers: false,
});
expect(result.overallSuccess).toBe(false);
expect(result.localStorage).toEqual({ success: true, count: 1 });
expect(result.cookies).toEqual({ success: false, error: 'Error: Cookie denied' });
expect(result.error).toBe('Cookies: Error: Cookie denied');
});
it('should join multiple storage failure messages in result.error', async () => {
(chrome.scripting.executeScript as any).mockRejectedValueOnce(new Error('Script denied'));
(chrome.cookies.getAll as any).mockRejectedValueOnce(new Error('Cookie denied'));
const result = await clearStorage(1, 'https://example.com', {
localStorage: true,
sessionStorage: false,
indexedDB: false,
cookies: true,
cacheStorage: false,
serviceWorkers: false,
});
expect(result.overallSuccess).toBe(false);
expect(result.localStorage).toEqual({ success: false, error: 'Error: Script denied' });
expect(result.cookies).toEqual({ success: false, error: 'Error: Cookie denied' });
expect(result.error).toBe(
'Local Storage: Error: Script denied\nCookies: Error: Cookie denied',
);
});
it('should preserve partial IndexedDB count when runCleanScript receives errors', async () => {
(chrome.scripting.executeScript as any).mockImplementationOnce(async () => [
{
result: {
count: 2,
errors: ['删除 IndexedDB 失败(db-c),请刷新后重试'],
},
},
]);
const result = await clearStorage(1, 'https://example.com', indexedDBClearOptions);
expect(result.overallSuccess).toBe(false);
expect(result.indexedDB).toEqual({
success: false,
count: 2,
error: '(已成功清理 2 个数据库,但部分失败)\n删除 IndexedDB 失败(db-c),请刷新后重试',
});
});
});
});
+226
View File
@@ -0,0 +1,226 @@
export const INDEXED_DB_DELETE_TIMEOUT_MS = 5000;
export const INDEXED_DB_CLEAR_STORE_TIMEOUT_MS = 5000;
export const INDEXED_DB_DELETE_FALLBACK_DELAY_MS = 200;
export interface IndexedDBCleanResult {
count: number;
errors?: string[];
}
/**
* 在页面上下文中清理当前 origin 的全部 IndexedDB。
* 设计为可传入 executeScript({ func }) 的自包含函数。
*
* 参数必须由调用方显式传入(不可使用模块常量作默认参数),
* 否则函数序列化到页面后缺省参数求值会 ReferenceError。
*/
export async function clearAllIndexedDBs(
deleteTimeoutMs: number,
clearStoreTimeoutMs: number,
fallbackDelayMs: number,
): Promise<IndexedDBCleanResult> {
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const waitForTransaction = (tx: IDBTransaction, timeoutMs: number): Promise<void> =>
new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Transaction timeout'));
}, timeoutMs);
tx.oncomplete = () => {
clearTimeout(timeoutId);
resolve();
};
tx.onerror = () => {
clearTimeout(timeoutId);
reject(tx.error ?? new Error('Transaction failed'));
};
tx.onabort = () => {
clearTimeout(timeoutId);
reject(tx.error ?? new Error('Transaction aborted'));
};
});
const openDatabase = (dbName: string, timeoutMs: number): Promise<IDBDatabase> =>
new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Open timeout'));
}, timeoutMs);
const openReq = indexedDB.open(dbName);
openReq.onerror = () => {
clearTimeout(timeoutId);
reject(openReq.error ?? new Error('Open failed'));
};
openReq.onsuccess = () => {
clearTimeout(timeoutId);
resolve(openReq.result);
};
});
const waitForDeleteDatabase = (dbName: string, timeoutMs: number) =>
new Promise<'deleted' | 'blocked' | 'timeout' | 'error'>((resolve) => {
let settled = false;
const settle = (status: 'deleted' | 'blocked' | 'timeout' | 'error') => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
resolve(status);
};
const deleteReq = indexedDB.deleteDatabase(dbName);
const timeoutId = setTimeout(() => {
console.warn('IndexedDB delete timeout:', dbName);
settle('timeout');
}, timeoutMs);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', dbName);
settle('blocked');
};
deleteReq.onsuccess = () => settle('deleted');
deleteReq.onerror = () => settle('error');
});
const formatDeleteError = (dbName: string, status: 'blocked' | 'timeout' | 'error'): string => {
if (status === 'blocked') {
return `页面仍占用 IndexedDB${dbName}),请刷新后重试或关闭占用该页面的连接`;
}
if (status === 'timeout') {
return `删除 IndexedDB 超时(${dbName}),请刷新后重试`;
}
return `删除 IndexedDB 失败(${dbName}),请刷新后重试`;
};
const clearObjectStores = async (
dbName: string,
): Promise<{ success: boolean; errors: string[] }> => {
if (typeof indexedDB.open !== 'function') {
return { success: false, errors: [] };
}
const clearStore = (store: IDBObjectStore, storeName: string): Promise<string | null> =>
new Promise((resolve) => {
const timeoutId = setTimeout(() => {
resolve(`清空 IndexedDB 超时(${dbName}/${storeName}),请刷新后重试`);
}, clearStoreTimeoutMs);
const clearReq = store.clear();
clearReq.onsuccess = () => {
clearTimeout(timeoutId);
resolve(null);
};
clearReq.onerror = () => {
clearTimeout(timeoutId);
resolve(`清空 IndexedDB 失败(${dbName}/${storeName}),请刷新后重试`);
};
});
let db: IDBDatabase | undefined;
try {
db = await openDatabase(dbName, clearStoreTimeoutMs);
} catch {
return {
success: false,
errors: [`无法打开 IndexedDB${dbName})进行清空,请刷新后重试`],
};
}
try {
const storeNames = Array.from(db.objectStoreNames);
if (storeNames.length === 0) {
return { success: true, errors: [] };
}
const transaction = db.transaction(storeNames, 'readwrite');
// 必须在 clear 请求完成前注册 oncomplete,否则事务可能已结束导致永久挂起
const transactionDone = waitForTransaction(transaction, clearStoreTimeoutMs);
void transactionDone.catch(() => undefined);
const errors = (
await Promise.all(
storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)),
)
).filter((error): error is string => Boolean(error));
if (errors.length > 0) {
try {
transaction.abort();
} catch {
// ignore abort failures on already-finished transactions
}
return { success: false, errors };
}
try {
await transactionDone;
} catch {
return {
success: false,
errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`],
};
}
return { success: true, errors: [] };
} catch {
return {
success: false,
errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`],
};
} finally {
db.close();
}
};
try {
if (typeof indexedDB.databases !== 'function') {
return { count: 0 };
}
const databases = await indexedDB.databases();
let count = 0;
const errors: string[] = [];
for (const db of databases) {
if (!db.name) continue;
const dbName = db.name;
// 先清空 object store,再尝试 delete。若先 delete 且 blocked
// delete 请求仍 pending 时再 open 同库容易超时(如 Bing ImageCacheDB 连续清理)。
const clearResult = await clearObjectStores(dbName);
if (clearResult.success) {
count++;
continue;
}
const status = await waitForDeleteDatabase(dbName, deleteTimeoutMs);
if (status === 'deleted') {
count++;
continue;
}
if (status === 'blocked' || status === 'timeout') {
await delay(fallbackDelayMs);
const retryClear = await clearObjectStores(dbName);
if (retryClear.success) {
count++;
} else if (retryClear.errors.length > 0) {
errors.push(...retryClear.errors);
} else {
errors.push(formatDeleteError(dbName, status));
}
continue;
}
if (clearResult.errors.length > 0) {
errors.push(...clearResult.errors);
} else {
errors.push(formatDeleteError(dbName, status));
}
}
return { count, errors };
} catch (error) {
return { count: 0, errors: [`读取或清理 IndexedDB 失败: ${String(error)}`] };
}
}
+83 -67
View File
@@ -1,12 +1,22 @@
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '@/pages/StorageCleaner/constants';
import {
clearAllIndexedDBs,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
INDEXED_DB_DELETE_TIMEOUT_MS,
type IndexedDBCleanResult,
} from '@/utils/indexedDbCleaner';
import { browser } from 'wxt/browser';
type CleanScriptResult = IndexedDBCleanResult;
export async function getCurrentTab() {
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
// We should ONLY care about the currently active tab in the last focused window.
// If it's a restricted URL, we return it anyway and let the caller handle the error display.
const [tab] = await chrome.tabs.query({
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
@@ -16,7 +26,7 @@ export async function getCurrentTab() {
}
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
const [fallbackTab] = await chrome.tabs.query({
const [fallbackTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
@@ -26,7 +36,7 @@ export async function getCurrentTab() {
export async function getCookieSize(url: string): Promise<number> {
try {
const cookies = await chrome.cookies.getAll({ url });
const cookies = await browser.cookies.getAll({ url });
const encoder = new TextEncoder();
// 估算:名称 + 值 + 域名 + 路径 的 UTF-8 字节数
return cookies.reduce(
@@ -44,29 +54,49 @@ export async function getCookieSize(url: string): Promise<number> {
}
}
/**
* 通用辅助:在指定标签页中执行脚本并返回结果
*
* @param tabId 标签页 ID
* @param func 在页面上下文中执行的函数
* @param errorLabel 错误日志前缀
* @param fallback 执行失败时的回退值
*/
async function runScript<T>(
type ExecuteInTabOptions<T> =
| { errorLabel: string; mode: 'fallback'; fallback: T }
| { errorLabel: string; mode: 'throw' };
async function executeInTab<T, A extends unknown[] = []>(
tabId: number,
func: () => T | Promise<T>,
errorLabel: string,
fallback: T,
func: (...args: A) => T | Promise<T>,
options: ExecuteInTabOptions<T>,
args?: A,
): Promise<T> {
try {
const [result] = await chrome.scripting.executeScript({ target: { tabId }, func });
return (result?.result as T) ?? fallback;
const [result] = await browser.scripting.executeScript({
target: { tabId },
func,
...(args ? { args } : {}),
});
const value = result?.result as T | undefined;
if (value !== undefined && value !== null) {
return value;
}
if (options.mode === 'fallback') {
return options.fallback;
}
return undefined as T;
} catch (error) {
console.error(`Failed to ${errorLabel}:`, error);
return fallback;
console.error(`Failed to ${options.errorLabel}:`, error);
if (options.mode === 'throw') {
throw error;
}
return options.fallback;
}
}
async function runScript<T, A extends unknown[] = []>(
tabId: number,
func: (...args: A) => T | Promise<T>,
errorLabel: string,
fallback: T,
args?: A,
): Promise<T> {
return executeInTab(tabId, func, { errorLabel, mode: 'fallback', fallback }, args);
}
export async function getLocalStorageSize(tabId: number): Promise<number> {
return runScript(
tabId,
@@ -164,12 +194,12 @@ export async function getServiceWorkerCount(tabId: number): Promise<number> {
export async function clearCookies(url: string): Promise<StorageCleanResult> {
try {
const cookies = await chrome.cookies.getAll({ url });
const cookies = await browser.cookies.getAll({ url });
for (const cookie of cookies) {
const protocol = cookie.secure ? 'https:' : 'http:';
const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
const cookieUrl = `${protocol}//${domain}${cookie.path}`;
await chrome.cookies.remove({
await browser.cookies.remove({
url: cookieUrl,
name: cookie.name,
storeId: cookie.storeId,
@@ -188,16 +218,33 @@ export async function clearCookies(url: string): Promise<StorageCleanResult> {
* @param func 在页面上下文中执行的清理函数
* @param errorLabel 错误日志前缀
*/
async function runCleanScript(
async function runCleanScript<A extends unknown[] = []>(
tabId: number,
func: () => { count: number } | Promise<{ count: number }>,
func: (...args: A) => CleanScriptResult | Promise<CleanScriptResult>,
errorLabel: string,
args?: A,
): Promise<StorageCleanResult> {
const raw = await runScript(tabId, func, errorLabel, { count: 0 });
if (raw && typeof raw === 'object' && 'count' in raw) {
try {
const raw = await executeInTab(tabId, func, { errorLabel, mode: 'throw' }, args);
if (!raw || typeof raw !== 'object' || !('count' in raw)) {
return { success: false, error: 'No result returned' };
}
if (raw.errors?.length) {
const errorMsg = raw.errors.join('\n');
const partialHint = raw.count > 0 ? `(已成功清理 ${raw.count} 个数据库,但部分失败)\n` : '';
return {
success: false,
error: partialHint + errorMsg,
...(raw.count > 0 ? { count: raw.count } : {}),
};
}
return { success: true, count: raw.count };
} catch (error) {
console.error(`Failed to ${errorLabel}:`, error);
return { success: false, error: String(error) };
}
return { success: false, error: 'No result returned' };
}
async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
@@ -225,47 +272,11 @@ async function injectClearSessionStorage(tabId: number): Promise<StorageCleanRes
}
async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
return runCleanScript(
tabId,
async () => {
if (typeof indexedDB.databases !== 'function') {
return { count: 0 };
}
const databases = await indexedDB.databases();
let count = 0;
for (const db of databases) {
if (!db.name) continue;
const dbName = db.name;
try {
await new Promise<void>((resolve, reject) => {
const deleteReq = indexedDB.deleteDatabase(dbName);
const timeout = setTimeout(() => {
console.warn('IndexedDB delete timeout:', dbName);
resolve();
}, 5000);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', dbName);
clearTimeout(timeout);
resolve();
};
deleteReq.onsuccess = () => {
clearTimeout(timeout);
resolve();
};
deleteReq.onerror = () => {
clearTimeout(timeout);
reject(new Error(`Failed to delete ${dbName}`));
};
});
count++;
} catch (e) {
console.error('Delete DB error:', e);
}
}
return { count };
},
'clear IndexedDB',
);
return runCleanScript(tabId, clearAllIndexedDBs, 'clear IndexedDB', [
INDEXED_DB_DELETE_TIMEOUT_MS,
INDEXED_DB_CLEAR_STORE_TIMEOUT_MS,
INDEXED_DB_DELETE_FALLBACK_DELAY_MS,
]);
}
async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
@@ -333,6 +344,11 @@ export async function clearStorage(
);
if (failures.length > 0) {
result.overallSuccess = false;
result.error = CLEAN_OPTION_KEYS.flatMap((key) => {
const itemResult = result[key];
if (!itemResult || itemResult.success) return [];
return `${OPTION_LABELS[key]}: ${itemResult.error}`;
}).join('\n');
}
return result;