7167a43763
核心功能新增 工具套件扩展 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;修正类型检查命令
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
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;
|
||
}
|