Files
testing-tool/utils/storageCleaner.ts
LingandRX 7167a43763 Develop (#25)
核心功能新增
工具套件扩展
JSON 工具(差异比较、格式化、转 YAML/TOML、压缩)
Base64 转换器(文本/文件/图像编解码)
Markdown ↔ HTML 双向转换
JWT 解析器
文本统计工具
二维码生成/解析(替换库以减小体积)
表单工具(已移除)
表单映射、智能填充、高亮定位功能已移除
架构与工程改进
技术栈升级
引入 @webext-core/messaging 重构消息通信
添加 @dnd-kit 支持功能列表拖拽排序
引入 i18next 实现中英文国际化
使用 MUI 主题系统替代硬编码样式,支持暗色模式
代码质量
统一组件导出为默认导出
提取公共组件(TextInputArea、SwitchButtonGroup、PageHeader、DecodeResultPaper)
使用 useStorageState 钩子统一管理存储状态
添加 PageErrorBoundary 页面级错误边界
实现组件懒加载优化首屏性能
关键重构
路由系统重构 — 支持独立标签页模式,合并路由与功能配置
状态管理优化 — 存储状态增加快照机制、验证器和防抖处理
剪贴板与打印工具 — 统一为 async/await 形式,修复重复触发问题
存储清理器 — 使用 TextEncoder 准确计算 UTF-8 字节数,支持 TB 单位
测试与 CI
测试覆盖 — 为新增工具、公共组件、边界场景补充单元测试
CI 精简 — 移除 Firefox 测试,仅保留 Chrome;修正类型检查命令
2026-05-20 19:55:49 +08:00

431 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
import { formatBytes } from './format';
const RESTRICTED_PROTOCOLS = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
] as const;
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({
active: true,
lastFocusedWindow: true,
});
if (tab) {
return tab;
}
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
const [fallbackTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
return fallbackTab;
}
export function isRestrictedUrl(url?: string): boolean {
if (!url) return true;
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
export async function getCookieSize(url: string): Promise<number> {
try {
const cookies = await chrome.cookies.getAll({ url });
const encoder = new TextEncoder();
// 估算:名称 + 值 + 域名 + 路径 的 UTF-8 字节数
return cookies.reduce(
(acc, c) =>
acc +
encoder.encode(c.name).length +
encoder.encode(c.value).length +
encoder.encode(c.domain ?? '').length +
encoder.encode(c.path ?? '').length,
0,
);
} catch (error) {
console.error('Failed to get cookie size:', error);
return 0;
}
}
export async function getLocalStorageSize(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
try {
const encoder = new TextEncoder();
return Object.entries(localStorage).reduce(
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
0,
);
} catch {
return 0;
}
},
});
return (result?.result as number) || 0;
} catch (error) {
console.error('Failed to get LocalStorage size:', error);
return 0;
}
}
export async function getSessionStorageSize(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
try {
const encoder = new TextEncoder();
return Object.entries(sessionStorage).reduce(
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
0,
);
} catch {
return 0;
}
},
});
return (result?.result as number) || 0;
} catch (error) {
console.error('Failed to get SessionStorage size:', error);
return 0;
}
}
export async function getOriginStorageEstimate(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
try {
// 注意:navigator.storage.estimate() 返回的是整个 Origin 的估算值
// 包含 IndexedDB, CacheStorage, ServiceWorker 注册等
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
return estimate.usage || 0;
}
return 0;
} catch {
return 0;
}
},
});
return (result?.result as number) || 0;
} catch (error) {
console.error('Failed to get origin storage estimate:', error);
return 0;
}
}
export async function getCacheStorageSize(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
try {
if ('caches' in window) {
const keys = await caches.keys();
return keys.length; // 对于 CacheStorage,我们先返回缓存库的数量
}
return 0;
} catch {
return 0;
}
},
});
// 由于获取具体字节数较慢,这里返回的是缓存条目的数量标识,UI 上可以特殊处理
return (result?.result as number) || 0;
} catch (error) {
console.error('Failed to get CacheStorage size:', error);
return 0;
}
}
export async function getServiceWorkerCount(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
try {
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
return regs.length;
}
return 0;
} catch {
return 0;
}
},
});
return (result?.result as number) || 0;
} catch (error) {
console.error('Failed to get ServiceWorker count:', error);
return 0;
}
}
/**
* 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes
*
* @param bytes 字节数
* @returns 格式化后的字符串
*/
export function formatSize(bytes: number): string {
return formatBytes(bytes);
}
export async function clearCookies(url: string): Promise<StorageCleanResult> {
try {
const cookies = await chrome.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({
url: cookieUrl,
name: cookie.name,
storeId: cookie.storeId,
});
}
return { success: true, count: cookies.length };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const count = localStorage.length;
localStorage.clear();
return { count };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const count = sessionStorage.length;
sessionStorage.clear();
return { count };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
if (typeof indexedDB.databases === 'function') {
const databases = await indexedDB.databases();
let count = 0;
for (const db of databases) {
if (db.name) {
const dbName = db.name as string;
try {
await new Promise<void>((resolve, reject) => {
const deleteReq = indexedDB.deleteDatabase(dbName);
const timeout = setTimeout(() => {
console.warn('IndexedDB delete timeout:', dbName);
resolve(); // Timeout, move to next
}, 5000);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', dbName);
clearTimeout(timeout);
resolve(); // Blocked, move to next
};
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 };
}
return { error: 'databases_api_unavailable' };
},
});
if (result?.result && typeof result.result === 'object') {
if ('error' in result.result) {
return { success: false, error: String(result.result.error) };
}
if ('count' in result.result) {
return { success: true, count: result.result.count };
}
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
return { count: cacheNames.length };
}
return { count: 0 };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
return { count: registrations.length };
}
return { count: 0 };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function clearStorage(
tabId: number,
url: string,
options: StorageCleanerOptions,
): Promise<CleaningResult> {
const result: CleaningResult = { success: true };
if (options.localStorage) {
result.localStorage = await injectClearLocalStorage(tabId);
}
if (options.sessionStorage) {
result.sessionStorage = await injectClearSessionStorage(tabId);
}
if (options.indexedDB) {
result.indexedDB = await injectClearIndexedDB(tabId);
}
if (options.cookies) {
result.cookies = await clearCookies(url);
}
if (options.cacheStorage) {
result.cacheStorage = await injectClearCacheStorage(tabId);
}
if (options.serviceWorkers) {
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
}
// Check if any operation failed
const failures = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === false,
);
if (failures.length > 0) {
result.success = false;
}
return result;
}
export function formatCleaningResult(
result: CleaningResult,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
const parts: string[] = [];
const optionKeys: (keyof StorageCleanerOptions)[] = [
'localStorage',
'sessionStorage',
'indexedDB',
'cookies',
'cacheStorage',
'serviceWorkers',
];
for (const key of optionKeys) {
const r = result[key];
if (r?.success && r.count > 0) {
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`);
}
}
if (parts.length === 0) {
return t('storageCleaner:noDataToClean');
}
return t('storageCleaner:cleanedSummary', { items: parts.join(', ') });
}
export function isEmptyResult(result: CleaningResult): boolean {
const values = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
);
return values.length === 0;
}