diff --git a/src/app/cache.test.ts b/src/app/cache.test.ts new file mode 100644 index 000000000..c45c1cdaa --- /dev/null +++ b/src/app/cache.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { cacheInstance } from "./cache"; + +describe("Cache", () => { + beforeEach(async () => { + // 每个测试前清空缓存 + await cacheInstance.clear(); + }); + + describe("基本操作", () => { + it("应该能够设置和获取不同类型的值", async () => { + await cacheInstance.set("string", "hello"); + expect(await cacheInstance.get("string")).toBe("hello"); + + await cacheInstance.set("number", 42); + expect(await cacheInstance.get("number")).toBe(42); + + const obj = { name: "test", count: 1 }; + await cacheInstance.set("object", obj); + expect(await cacheInstance.get("object")).toEqual(obj); + + expect(await cacheInstance.get("non-existent")).toBeUndefined(); + }); + + it("应该能够批量设置值", async () => { + await cacheInstance.batchSet({ + key1: "value1", + key2: "value2", + key3: "value3", + }); + + expect(await cacheInstance.get("key1")).toBe("value1"); + expect(await cacheInstance.get("key2")).toBe("value2"); + expect(await cacheInstance.get("key3")).toBe("value3"); + }); + }); + + describe("has/del/clear/list 方法", () => { + it("应该正确检查键是否存在", async () => { + await cacheInstance.set("existing-key", "value"); + expect(await cacheInstance.has("existing-key")).toBe(true); + expect(await cacheInstance.has("non-existing-key")).toBe(false); + + await cacheInstance.set("undefined-key", undefined); + expect(await cacheInstance.has("undefined-key")).toBe(false); + }); + + it("应该能够删除键和清空缓存", async () => { + await cacheInstance.batchSet({ key1: "v1", key2: "v2" }); + + await cacheInstance.del("key1"); + expect(await cacheInstance.has("key1")).toBe(false); + expect(await cacheInstance.has("key2")).toBe(true); + + await cacheInstance.clear(); + expect(await cacheInstance.has("key2")).toBe(false); + }); + + it("应该能够批量删除键", async () => { + await cacheInstance.batchSet({ key1: "v1", key2: "v2", key3: "v3", key4: "v4" }); + + await (cacheInstance as any).dels(["key1", "key2", "key3"]); + expect(await cacheInstance.has("key1")).toBe(false); + expect(await cacheInstance.has("key2")).toBe(false); + expect(await cacheInstance.has("key3")).toBe(false); + expect(await cacheInstance.has("key4")).toBe(true); + }); + + it("应该返回所有键的列表", async () => { + await cacheInstance.batchSet({ key1: "v1", key2: "v2", key3: "v3" }); + const keys = await cacheInstance.list(); + expect(keys).toContain("key1"); + expect(keys).toContain("key2"); + expect(keys).toContain("key3"); + }); + }); + + describe("getOrSet 方法", () => { + it("应该懒加载和缓存值", async () => { + const setFn = vi.fn(() => "computed-value"); + const value1 = await cacheInstance.getOrSet("new-key", setFn); + expect(setFn).toHaveBeenCalledTimes(1); + expect(value1).toBe("computed-value"); + + const setFn2 = vi.fn(() => "new-value"); + const value2 = await cacheInstance.getOrSet("new-key", setFn2); + expect(setFn2).not.toHaveBeenCalled(); + expect(value2).toBe("computed-value"); + }); + }); + + describe("tx 方法(事务)", () => { + it("应该能够读取、修改和删除值", async () => { + // 设置值 + await cacheInstance.tx("tx-key", (val, tx) => { + tx.set("new-value"); + }); + expect(await cacheInstance.get("tx-key")).toBe("new-value"); + + // 基于现有值修改 + await cacheInstance.tx("tx-key", (val: string | undefined, tx) => { + tx.set((val || "") + "-updated"); + }); + expect(await cacheInstance.get("tx-key")).toBe("new-value-updated"); + + // 删除值 + await cacheInstance.tx("tx-key", (val, tx) => { + tx.del(); + }); + expect(await cacheInstance.has("tx-key")).toBe(false); + }); + }); + + describe("incr 方法", () => { + it("应该能够增加和减少数值", async () => { + expect(await cacheInstance.incr("counter", 5)).toBe(5); + expect(await cacheInstance.incr("counter", 3)).toBe(8); + expect(await cacheInstance.incr("counter", -2)).toBe(6); + expect(await cacheInstance.get("counter")).toBe(6); + }); + }); + + describe("并发操作", () => { + it("同一个键的事务应该串行化执行", async () => { + await cacheInstance.set("tx-counter", 0); + const executionOrder: number[] = []; + + const promises = [ + cacheInstance.tx("tx-counter", async (val: number | undefined, tx) => { + executionOrder.push(1); + await new Promise((resolve) => setTimeout(resolve, 30)); + tx.set((val || 0) + 1); + return 1; + }), + cacheInstance.tx("tx-counter", async (val: number | undefined, tx) => { + executionOrder.push(2); + await new Promise((resolve) => setTimeout(resolve, 20)); + tx.set((val || 0) + 1); + return 2; + }), + cacheInstance.tx("tx-counter", async (val: number | undefined, tx) => { + executionOrder.push(3); + await new Promise((resolve) => setTimeout(resolve, 10)); + tx.set((val || 0) + 1); + return 3; + }), + ]; + + const results = await Promise.all(promises); + + // 验证事务按顺序执行,每个事务都返回正确的递增值 + expect(executionOrder).toEqual([1, 2, 3]); + expect(results).toEqual([1, 2, 3]); + expect(await cacheInstance.get("tx-counter")).toBe(3); + }); + + it("不同键的事务可以并发执行", async () => { + const startTime = Date.now(); + + await Promise.all([ + cacheInstance.tx("key-a", async (val, tx) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + tx.set("value-a"); + }), + cacheInstance.tx("key-b", async (val, tx) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + tx.set("value-b"); + }), + cacheInstance.tx("key-c", async (val, tx) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + tx.set("value-c"); + }), + ]); + + const duration = Date.now() - startTime; + + // 如果并发执行,总时间应该接近单个操作的时间(约50ms) + // 如果串行执行,总时间会接近 150ms + expect(duration).toBeLessThan(100); + }); + + it("并发 incr 操作应该正确累加", async () => { + await cacheInstance.set("incr-concurrent", 0); + + const promises = Array.from({ length: 10 }, () => cacheInstance.incr("incr-concurrent", 1)); + const results = await Promise.all(promises); + + // 每个操作都应该返回唯一的递增值 + const sortedResults = [...results].sort((a, b) => a - b); + expect(sortedResults).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(await cacheInstance.get("incr-concurrent")).toBe(10); + }); + }); + + describe("边界情况", () => { + it("应该能够处理特殊值", async () => { + await cacheInstance.set("empty-value", ""); + expect(await cacheInstance.get("empty-value")).toBe(""); + + await cacheInstance.set("null-value", null); + expect(await cacheInstance.get("null-value")).toBe(null); + + await cacheInstance.set("overwrite", "original"); + await cacheInstance.set("overwrite", "updated"); + expect(await cacheInstance.get("overwrite")).toBe("updated"); + }); + }); +}); diff --git a/src/app/cache.ts b/src/app/cache.ts index 4d304442f..9ac047288 100644 --- a/src/app/cache.ts +++ b/src/app/cache.ts @@ -81,6 +81,19 @@ class ExtCache implements CacheStorage { }); } + dels(keys: string[]): Promise { + return new Promise((resolve) => { + chrome.storage.session.remove(keys, () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.storage.session.remove:", lastError); + // 无视storage API错误,继续执行 + } + resolve(); + }); + }); + } + clear(): Promise { return new Promise((resolve) => { chrome.storage.session.clear(() => { diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 59b628d11..3b68a51c4 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -28,9 +28,7 @@ import { type ResourceService } from "./resource"; import { type ValueService } from "./value"; import { compileScriptCode } from "../content/utils"; import { type SystemConfig } from "@App/pkg/config/config"; -import { localePath, watchLanguageChange } from "@App/locales/locales"; import { arrayMove } from "@dnd-kit/sortable"; -import { DocumentationSite } from "@App/app/const"; import type { TScriptRunStatus, TDeleteScript, @@ -39,7 +37,6 @@ import type { TSortedScript, TInstallScriptParams, } from "../queue"; -import { timeoutExecution } from "@App/pkg/utils/timer"; import { buildScriptRunResourceBasic, selfMetadataUpdate } from "./utils"; import { BatchUpdateListActionCode, @@ -54,8 +51,6 @@ import { CompiledResourceDAO } from "@App/app/repo/resource"; import { initRegularUpdateCheck } from "./regular_updatecheck"; // import { gzip as pakoGzip } from "pako"; -const cIdKey = `(cid_${Math.random()})`; - export type TCheckScriptUpdateOption = Partial< { checkType: "user"; noUpdateCheck?: number } | ({ checkType: "system" } & Record) >; @@ -84,13 +79,15 @@ export class ScriptService { listenerScriptInstall() { // 初始化脚本安装监听 - chrome.webRequest.onBeforeRequest.addListener( - (req: chrome.webRequest.OnBeforeRequestDetails) => { - // 处理url, 实现安装脚本 - if (req.method !== "GET") { - return undefined; + chrome.webNavigation.onBeforeNavigate.addListener( + (req: chrome.webNavigation.WebNavigationBaseCallbackDetails) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webNavigation.onBeforeNavigate:", lastError); + return; } - let targetUrl: string | null = null; + // 处理url, 实现安装脚本 + let targetUrl: string; // 判断是否为 file:///*/*.user.js if (req.url.startsWith("file://") && req.url.endsWith(".user.js")) { targetUrl = req.url; @@ -101,11 +98,12 @@ export class ScriptService { return undefined; } // 判断是否有url参数 - if (!reqUrl.hash.includes("url=")) { + const idx = reqUrl.hash.indexOf("url="); + if (idx < 0) { return undefined; } // 获取url参数 - targetUrl = reqUrl.hash.split("url=")[1]; + targetUrl = reqUrl.hash.substring(idx + 4); } // 读取脚本url内容, 进行安装 const logger = this.logger.with({ url: targetUrl }); @@ -153,15 +151,15 @@ export class ScriptService { }); }, { - urls: [ - `${DocumentationSite}/docs/script_installation/*`, - `${DocumentationSite}/en/docs/script_installation/*`, - "https://www.tampermonkey.net/script_installation.php*", - "file:///*/*.user.js*", + url: [ + { schemes: ["http", "https"], hostEquals: "docs.scriptcat.org", pathPrefix: "/docs/script_installation/" }, + { schemes: ["http", "https"], hostEquals: "docs.scriptcat.org", pathPrefix: "/en/docs/script_installation/" }, + { schemes: ["http", "https"], hostEquals: "www.tampermonkey.net", pathPrefix: "/script_installation.php" }, + { schemes: ["file"], pathSuffix: ".user.js" }, ], - types: ["main_frame"], } ); + // 兼容 chrome 内核 < 128 处理 const browserType = getBrowserType(); const addResponseHeaders = browserType.chrome && browserType.chromeVersion >= 128; @@ -243,87 +241,78 @@ export class ScriptService { requestDomains: ["bitbucket.org"], // Chrome 101+ }, ]; - watchLanguageChange(() => { - const rules = conditions.map((condition, idx) => { + const installPageURL = chrome.runtime.getURL("src/install.html"); + const rules = conditions.map((condition, idx) => { + Object.assign(condition, { + excludedTabIds: [chrome.tabs.TAB_ID_NONE], + }); + if (addResponseHeaders) { Object.assign(condition, { - excludedTabIds: [chrome.tabs.TAB_ID_NONE], - }); - if (addResponseHeaders) { - Object.assign(condition, { - responseHeaders: [ - { - header: "Content-Type", - values: [ - "text/javascript*", - "application/javascript*", - "text/html*", - "text/plain*", - "application/octet-stream*", - "application/force-download*", - ], - }, - ], - }); - } - return { - id: 1000 + idx, - priority: 1, - action: { - type: "redirect" as chrome.declarativeNetRequest.RuleActionType, - redirect: { - regexSubstitution: `${DocumentationSite}${localePath}/docs/script_installation/#url=\\1`, + responseHeaders: [ + { + header: "Content-Type", + values: [ + "text/javascript*", + "application/javascript*", + "text/html*", + "text/plain*", + "application/octet-stream*", + "application/force-download*", + ], }, + ], + }); + } + return { + id: 1000 + idx, + priority: 1, + action: { + type: "redirect" as chrome.declarativeNetRequest.RuleActionType, + redirect: { + regexSubstitution: `${installPageURL}?url=\\1`, }, - condition: condition, - } as chrome.declarativeNetRequest.Rule; - }); - // 重定向到脚本安装页 - chrome.declarativeNetRequest.updateDynamicRules( - { - removeRuleIds: [1], }, - () => { - if (chrome.runtime.lastError) { - console.error( - "chrome.runtime.lastError in chrome.declarativeNetRequest.updateDynamicRules:", - chrome.runtime.lastError - ); - } + condition: condition, + } as chrome.declarativeNetRequest.Rule; + }); + // 重定向到脚本安装页 + chrome.declarativeNetRequest.updateDynamicRules( + { + removeRuleIds: [1], + }, + () => { + if (chrome.runtime.lastError) { + console.error( + "chrome.runtime.lastError in chrome.declarativeNetRequest.updateDynamicRules:", + chrome.runtime.lastError + ); } - ); - chrome.declarativeNetRequest.updateSessionRules( - { - removeRuleIds: [...rules.map((rule) => rule.id)], - addRules: rules, - }, - () => { - if (chrome.runtime.lastError) { - console.error( - "chrome.runtime.lastError in chrome.declarativeNetRequest.updateSessionRules:", - chrome.runtime.lastError - ); - } + } + ); + chrome.declarativeNetRequest.updateSessionRules( + { + removeRuleIds: [...rules.map((rule) => rule.id)], + addRules: rules, + }, + () => { + if (chrome.runtime.lastError) { + console.error( + "chrome.runtime.lastError in chrome.declarativeNetRequest.updateSessionRules:", + chrome.runtime.lastError + ); } - ); - }); + } + ); } public async openInstallPageByUrl( url: string, options: { source: InstallSource; byWebRequest?: boolean } ): Promise<{ success: boolean; msg: string }> { - const uuid = uuidv4(); try { - await this.openUpdateOrInstallPage(uuid, url, options, false); - timeoutExecution( - `${cIdKey}_cleanup_${uuid}`, - () => { - // 清理缓存 - cacheInstance.del(`${CACHE_KEY_SCRIPT_INFO}${uuid}`); - }, - 30 * 1000 - ); - await openInCurrentTab(`/src/install.html?uuid=${uuid}`); + const installPageUrl = await this.getInstallPageUrl(url, options); + if (!installPageUrl) throw new Error("getInstallPageUrl failed"); + await openInCurrentTab(installPageUrl); return { success: true, msg: "" }; } catch (err: any) { console.error(err); @@ -331,6 +320,20 @@ export class ScriptService { } } + public async getInstallPageUrl( + url: string, + options: { source: InstallSource; byWebRequest?: boolean } + ): Promise { + const uuid = uuidv4(); + try { + await this.openUpdateOrInstallPage(uuid, url, options, false); + return `/src/install.html?uuid=${uuid}`; + } catch (err: any) { + console.error(err); + return ""; + } + } + // 直接通过url静默安装脚本 async installByUrl(url: string, source: InstallSource, subscribeUrl?: string) { const uuid = uuidv4(); diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index 9c26a588d..339352bdd 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -281,7 +281,8 @@ "script_requires": "crwdns8440:0crwdne8440:0", "cookie_warning": "crwdns8442:0crwdne8442:0", "scheduled_script_description_title": "crwdns8706:0crwdne8706:0", - "scheduled_script_description_description": "crwdns8708:0{{expression}}crwdnd8708:0{{time}}crwdne8708:0", + "scheduled_script_description_description_expr": "crwdns8708:0", + "scheduled_script_description_description_next": "crwdns8708:0", "background_script_description": "crwdns8444:0crwdne8444:0", "install_success": "crwdns8446:0crwdne8446:0", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "crwdns12778:0crwdne12778:0", "header_other_update": "crwdns12784:0crwdne12784:0" }, + "downloading_status_text": "Downloading. Received {{bytes}}.", + "install_page_loading": "Installation page loading", + "invalid_page": "Invalid page", "background_script_tag": "crwdns8460:0crwdne8460:0", "scheduled_script_tag": "crwdns8462:0crwdne8462:0", "background_script": "crwdns8464:0crwdne8464:0", diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 5bbdc6f15..116925ff4 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -281,7 +281,8 @@ "script_requires": "Skript referenziert die folgenden externen Ressourcen", "cookie_warning": "Achtung: Dieses Skript beantragt Cookie-Operationsberechtigung. Dies ist eine gefährliche Berechtigung, bitte stellen Sie die Sicherheit des Skripts sicher.", "scheduled_script_description_title": "Dies ist ein geplantes Skript. Wenn aktiviert, wird es zu bestimmten Zeiten automatisch ausgeführt und kann im Panel manuell gesteuert werden.", - "scheduled_script_description_description": "Geplante Aufgaben-Ausdruck: {{expression}}, letzte Ausführungszeit: {{time}}", + "scheduled_script_description_description_expr": "Geplante Aufgaben-Ausdruck", + "scheduled_script_description_description_next": "Letzte Ausführungszeit:", "background_script_description": "Dies ist ein Hintergrundskript. Wenn aktiviert, wird es automatisch einmal ausgeführt, wenn der Browser geöffnet wird, und kann im Panel manuell gesteuert werden.", "install_success": "Installation erfolgreich", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "Automatisches Schließen in $0 Sekunden", "header_other_update": "Andere verfügbare Updates" }, + "downloading_status_text": "Wird heruntergeladen. {{bytes}} empfangen.", + "install_page_loading": "Installationsseite wird geladen", + "invalid_page": "Ungültige Seite", "background_script_tag": "Dies ist ein Hintergrundskript", "scheduled_script_tag": "Dies ist ein geplantes Skript", "background_script": "Hintergrundskript", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 956cba216..5b9f83a51 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -281,7 +281,8 @@ "script_requires": "Script requires the following external resources", "cookie_warning": "Please note, this script requests access to Cookie permissions, which is a dangerous permission. Please verify the security of the script.", "scheduled_script_description_title": "This is a scheduled script, which will automatically run at a specific time once enabled and can be manually controlled in the panel.", - "scheduled_script_description_description": "Scheduled task expression: {{expression}}, most recent run time: {{time}}", + "scheduled_script_description_description_expr": "Scheduled task expression:", + "scheduled_script_description_description_next": "Most recent run time:", "background_script_description": "This is a background script, which will automatically run once when the browser opens once enabled, and can be manually controlled in the panel.", "install_success": "Install Successful", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "Auto-closing in $0 seconds", "header_other_update": "Other available updates" }, + "downloading_status_text": "Downloading. Received {{bytes}}.", + "install_page_loading": "Installation page loading", + "invalid_page": "Invalid page", "background_script_tag": "This is a Background Script", "scheduled_script_tag": "This is a Scheduled Script", "background_script": "Background Script", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index cd74de678..86c9b8eab 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -281,7 +281,8 @@ "script_requires": "スクリプトは以下の外部リソースを参照しています", "cookie_warning": "注意:このスクリプトはCookieの操作権限を申請します。これは危険な権限ですので、スクリプトの安全性を確認してください。", "scheduled_script_description_title": "これはスケジュールスクリプトです。有効にすると特定の時間に自動実行され、手動操作も可能です。", - "scheduled_script_description_description": "スケジュールタスク式:{{expression}}、最近の実行時間:{{time}}", + "scheduled_script_description_description_expr": "スケジュールタスク表現:", + "scheduled_script_description_description_next": "最近の実行時間:", "background_script_description": "これはバックグラウンドスクリプトです。有効にするとブラウザを開いたときに自動的に一度実行され、パネルで手動制御できます。", "install_success": "インストールに成功しました", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "$0 秒後に自動的に閉じます", "header_other_update": "その他の利用可能な更新" }, + "downloading_status_text": "ダウンロード中。{{bytes}} を受信しました。", + "install_page_loading": "インストールページを読み込み中", + "invalid_page": "無効なページ", "background_script_tag": "これはバックグラウンドスクリプトです", "scheduled_script_tag": "これはスケジュールスクリプトです", "background_script": "バックグラウンドスクリプト", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index d10553f5e..7ba90d4f8 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -281,7 +281,8 @@ "script_requires": "Скрипт ссылается на следующие внешние ресурсы", "cookie_warning": "Обратите внимание, что этот скрипт запрашивает разрешения на операции с Cookie. Это опасное разрешение, пожалуйста, убедитесь в безопасности скрипта.", "scheduled_script_description_title": "Это запланированный скрипт. После включения он будет автоматически выполняться в определенное время и может управляться вручную с панели.", - "scheduled_script_description_description": "Выражение планировщика: {{expression}}, последнее время выполнения: {{time}}", + "scheduled_script_description_description_expr": "Выражение планировщика", + "scheduled_script_description_description_next": "Последнее время выполнения:", "background_script_description": "Это фоновый скрипт. После включения он будет автоматически выполняться один раз при открытии браузера и может управляться вручную с панели.", "install_success": "Установка успешна", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "Автоматическое закрытие через $0 сек.", "header_other_update": "Другие доступные обновления" }, + "downloading_status_text": "Загрузка. Получено {{bytes}}.", + "install_page_loading": "Загрузка страницы установки", + "invalid_page": "Недействительная страница", "background_script_tag": "Это фоновый скрипт", "scheduled_script_tag": "Это запланированный скрипт", "background_script": "Фоновый скрипт", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index c6a8c5c79..fe88f640c 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -281,7 +281,8 @@ "script_requires": "Script yêu cầu các tài nguyên bên ngoài sau", "cookie_warning": "Xin lưu ý, script này yêu cầu quyền truy cập cookie, đây là một quyền nguy hiểm. Vui lòng xác minh tính bảo mật của script.", "scheduled_script_description_title": "Đây là script hẹn giờ, sẽ tự động chạy vào một thời điểm cụ thể sau khi được bật và có thể được điều khiển thủ công trong bảng điều khiển.", - "scheduled_script_description_description": "Biểu thức tác vụ hẹn giờ: {{expression}}, thời gian chạy gần nhất: {{time}}", + "scheduled_script_description_description_expr": "Biểu thức tác vụ hẹn giờ:", + "scheduled_script_description_description_next": "Thời gian chạy gần nhất:", "background_script_description": "Đây là script nền, sẽ tự động chạy một lần khi trình duyệt mở sau khi được bật và có thể được điều khiển thủ công trong bảng điều khiển.", "install_success": "Cài đặt thành công", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "Tự đóng trong $0 giây", "header_other_update": "Các bản cập nhật khác" }, + "downloading_status_text": "Đang tải xuống. Đã nhận {{bytes}}.", + "install_page_loading": "Đang tải trang cài đặt", + "invalid_page": "Trang không hợp lệ", "background_script_tag": "Đây là một script nền", "scheduled_script_tag": "Đây là một script hẹn giờ", "background_script": "Script nền", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 15f754733..d6789b204 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -281,7 +281,8 @@ "script_requires": "脚本引用了下列外部资源", "cookie_warning": "请注意,本脚本会申请 Cookie 的操作权限,这是一个危险的权限,请确认脚本的安全性。", "scheduled_script_description_title": "这是一个定时脚本,启用后将在特定时间自动运行,并可在面板中手动控制。", - "scheduled_script_description_description": "定时任务表达式:{{expression}},最近一次运行时间:{{time}}", + "scheduled_script_description_description_expr": "定时任务表达式:", + "scheduled_script_description_description_next": "最近一次运行时间:", "background_script_description": "这是一个后台脚本,启用后将在浏览器打开时自动运行一次,并可在面板中手动控制。", "install_success": "安装成功", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "$0 秒后自动关闭", "header_other_update": "其他可用更新" }, + "downloading_status_text": "正在下载。已接收 {{bytes}}。", + "install_page_loading": "安装页面加载中", + "invalid_page": "无效页面", "background_script_tag": "这是一个后台脚本", "scheduled_script_tag": "这是一个定时脚本", "background_script": "后台脚本", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 1f2664b31..5b6142e58 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -281,7 +281,8 @@ "script_requires": "腳本引用了以下外部資源", "cookie_warning": "請注意,此腳本會要求 Cookie 的操作權限,這是一項危險的權限,請確認腳本的安全性。", "scheduled_script_description_title": "這是一個排程腳本,啟用後將在特定時間自動執行,並可在控制面板中手動控制。", - "scheduled_script_description_description": "排程任務表達式:{{expression}},最近一次執行時間:{{time}}", + "scheduled_script_description_description_expr": "排程任務表達式:", + "scheduled_script_description_description_next": "最近一次執行時間:", "background_script_description": "這是一個背景腳本,啟用後將在瀏覽器開啟時自動執行一次,並可在控制面板中手動控制。", "install_success": "安裝成功", "install": { @@ -321,6 +322,9 @@ "status_autoclose": "$0 秒後自動關閉", "header_other_update": "其他可用更新" }, + "downloading_status_text": "正在下載。已接收 {{bytes}}。", + "install_page_loading": "安裝頁載入中", + "invalid_page": "無效頁面", "background_script_tag": "這是一個背景腳本", "scheduled_script_tag": "這是一個排程腳本", "background_script": "背景腳本", diff --git a/src/manifest.json b/src/manifest.json index 381649613..ab7359b3f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -42,7 +42,8 @@ "notifications", "clipboardWrite", "unlimitedStorage", - "declarativeNetRequest" + "declarativeNetRequest", + "webNavigation" ], "optional_permissions": [ "userScripts" @@ -54,5 +55,11 @@ "pages": [ "src/sandbox.html" ] - } + }, + "web_accessible_resources": [ + { + "resources": ["/src/install.html"], + "matches": [""] + } + ] } \ No newline at end of file diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx index 4fa13d33e..879f87c37 100644 --- a/src/pages/components/CodeEditor/index.tsx +++ b/src/pages/components/CodeEditor/index.tsx @@ -53,6 +53,7 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod folding: true, foldingStrategy: "indentation", automaticLayout: true, + scrollbar: { alwaysConsumeMouseWheel: false }, overviewRulerBorder: false, scrollBeyondLastLine: false, readOnly: true, @@ -73,6 +74,7 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod folding: true, foldingStrategy: "indentation", automaticLayout: true, + scrollbar: { alwaysConsumeMouseWheel: false }, overviewRulerBorder: false, scrollBeyondLastLine: false, readOnly: !editable, diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 75cbbb9d9..ee282d963 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -2,7 +2,6 @@ import { Avatar, Button, Dropdown, - Grid, Message, Menu, Space, @@ -29,6 +28,10 @@ import { type FTInfo, startFileTrack, unmountFileTrack } from "@App/pkg/utils/fi import { cleanupOldHandles, loadHandle, saveHandle } from "@App/pkg/utils/filehandle-db"; import { dayFormat } from "@App/pkg/utils/day_format"; import { intervalExecution, timeoutExecution } from "@App/pkg/utils/timer"; +import { useSearchParams } from "react-router-dom"; +import { CACHE_KEY_SCRIPT_INFO } from "@App/app/cache_key"; +import { cacheInstance } from "@App/app/cache"; +import { formatBytes } from "@App/pkg/utils/utils"; type ScriptOrSubscribe = Script | Subscribe; @@ -41,8 +44,132 @@ interface PermissionItem { type Permission = PermissionItem[]; -const closeWindow = () => { - window.close(); +const closeWindow = (doBackwards: boolean) => { + if (doBackwards) { + history.go(-1); + } else { + window.close(); + } +}; + +const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any }) => { + let origin; + try { + origin = new URL(url).origin; + } catch { + throw new Error(`Invalid url: ${url}`); + } + const response = await fetch(url, { + headers: { + "Cache-Control": "no-cache", + Accept: "text/javascript,application/javascript,text/plain,application/octet-stream,application/force-download", + // 参考:加权 Accept-Encoding 值说明 + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#weighted_accept-encoding_values + "Accept-Encoding": "br;q=1.0, gzip;q=0.8, *;q=0.1", + Origin: origin, + }, + referrer: origin + "/", + }); + + if (!response.ok) { + throw new Error(`Fetch failed with status ${response.status}`); + } + + if (!response.body || !response.headers) { + throw new Error("No response body or headers"); + } + if (response.headers.get("content-type")?.includes("text/html")) { + throw new Error("Response is text/html, not a valid UserScript"); + } + + const reader = response.body.getReader(); + + // 读取数据 + let receivedLength = 0; // 当前已接收的长度 + const chunks = []; // 已接收的二进制分片数组(用于组装正文) + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + chunks.push(value); + receivedLength += value.length; + onProgress?.({ receivedLength }); + } + + // 检查 Content-Type 中的 charset + const contentType = response.headers.get("content-type") || ""; + const charsetMatch = contentType.match(/charset=([^;]+)/i); + const charset = charsetMatch ? charsetMatch[1].toLowerCase() : "utf-8"; + + // 合并分片(chunks) + const chunksAll = new Uint8Array(receivedLength); + let position = 0; + for (const chunk of chunks) { + chunksAll.set(chunk, position); + position += chunk.length; + } + + // 使用检测到的 charset 解码 + let code; + try { + code = new TextDecoder(charset).decode(chunksAll); + } catch (e: any) { + throw new Error(`Failed to decode response with charset ${charset}: ${e.message}`); + } + + const metadata = parseMetadata(code); + if (!metadata) { + throw new Error("parse script info failed"); + } + + return { code, metadata }; +}; + +const cleanupStaleInstallInfo = (uuid: string) => { + // 页面打开时不清除当前uuid,每30秒更新一次记录 + const f = () => { + cacheInstance.tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { + val = val || {}; + val[uuid] = Date.now(); + tx.set(val); + }); + }; + f(); + setInterval(f, 30_000); + + // 页面打开后清除旧记录 + const delay = Math.floor(5000 * Math.random()) + 10000; // 使用随机时间避免浏览器重启时大量Tabs同时执行清除 + timeoutExecution( + `${cIdKey}cleanupStaleInstallInfo`, + () => { + cacheInstance + .tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { + const now = Date.now(); + const keeps = new Set(); + const out: Record = {}; + for (const [k, ts] of Object.entries(val ?? {})) { + if (ts > 0 && now - ts < 60_000) { + keeps.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); + out[k] = ts; + } + } + tx.set(out); + return keeps; + }) + .then(async (keeps) => { + const list = await cacheInstance.list(); + const filtered = list.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); + if (filtered.length) { + // 清理缓存 + cacheInstance.dels(filtered); + } + }); + }, + delay + ); }; const cIdKey = `(cid_${Math.random()})`; @@ -58,6 +185,9 @@ function App() { const [isUpdate, setIsUpdate] = useState(false); const [localFileHandle, setLocalFileHandle] = useState(null); const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [loaded, setLoaded] = useState(false); + const [doBackwards, setDoBackwards] = useState(false); const installOrUpdateScript = async (newScript: Script, code: string) => { if (newScript.ignoreVersion) newScript.ignoreVersion = ""; @@ -84,13 +214,25 @@ function App() { const initAsync = async () => { try { - const locationUrl = new URL(window.location.href); - const uuid = locationUrl.searchParams.get("uuid"); + const uuid = searchParams.get("uuid"); + const fid = searchParams.get("file"); let info: ScriptInfo | undefined; let isKnownUpdate: boolean = false; + + // 如果没有 uuid 和 file,跳过初始化逻辑 + if (!uuid && !fid) { + return; + } + + if (window.history.length > 1) { + setDoBackwards(true); + } + setLoaded(true); + let paramOptions = {}; if (uuid) { const cachedInfo = await scriptClient.getInstallInfo(uuid); + cleanupStaleInstallInfo(uuid); if (cachedInfo?.[0]) isKnownUpdate = true; info = cachedInfo?.[1] || undefined; paramOptions = cachedInfo?.[2] || {}; @@ -99,7 +241,6 @@ function App() { } } else { // 检查是不是本地文件安装 - const fid = locationUrl.searchParams.get("file"); if (!fid) { throw new Error("url param - local file id is not found"); } @@ -173,8 +314,8 @@ function App() { }; useEffect(() => { - initAsync(); - }, []); + !loaded && initAsync(); + }, [searchParams, loaded]); const [watchFile, setWatchFile] = useState(false); const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); @@ -209,16 +350,16 @@ function App() { } return permissions; - }, [scriptInfo, metadataLive]); + }, [scriptInfo, metadataLive, t]); - const description = useMemo(() => { - const description: JSX.Element[] = []; + const descriptionParagraph = useMemo(() => { + const ret: JSX.Element[] = []; - if (!scriptInfo) return description; + if (!scriptInfo) return ret; const isCookie = metadataLive.grant?.some((val) => val === "GM_cookie"); if (isCookie) { - description.push( + ret.push( {t("cookie_warning")} @@ -226,21 +367,21 @@ function App() { } if (metadataLive.crontab) { - description.push({t("scheduled_script_description_title")}); - description.push( - - {t("scheduled_script_description_description", { - expression: metadataLive.crontab[0], - time: nextTime(metadataLive.crontab[0]), - })} - + ret.push({t("scheduled_script_description_title")}); + ret.push( +
+ {t("scheduled_script_description_description_expr")} + {metadataLive.crontab[0]} + {t("scheduled_script_description_description_next")} + {nextTime(metadataLive.crontab[0])} +
); } else if (metadataLive.background) { - description.push({t("background_script_description")}); + ret.push({t("background_script_description")}); } - return description; - }, [scriptInfo, metadataLive]); + return ret; + }, [scriptInfo, metadataLive, t]); const antifeatures: { [key: string]: { color: string; title: string; description: string } } = { "referral-link": { @@ -285,7 +426,7 @@ function App() { if (upsertScript) { document.title = `${!isUpdate ? t("install_script") : t("update_script")} - ${i18nName(upsertScript!)} - ScriptCat`; } - }, [isUpdate, scriptInfo, upsertScript]); + }, [isUpdate, scriptInfo, upsertScript, t]); // 设置脚本状态 useEffect(() => { @@ -339,7 +480,7 @@ function App() { if (shouldClose) { setTimeout(() => { - closeWindow(); + closeWindow(doBackwards); }, 500); } } catch (e) { @@ -353,7 +494,7 @@ function App() { if (noMoreUpdates && scriptInfo && !scriptInfo.userSubscribe) { scriptClient.setCheckUpdateUrl(scriptInfo.uuid, false); } - closeWindow(); + closeWindow(doBackwards); }; const { @@ -466,123 +607,138 @@ function App() { }; }, [memoWatchFile]); - return ( -
- - - -
- {upsertScript?.metadata.icon && ( - - {upsertScript.name} - - )} - - {upsertScript && i18nName(upsertScript)} - - - - -
-
- {upsertScript && i18nDescription(upsertScript!)} -
-
- {`${t("author")}: ${metadataLive.author}`} + // 检查是否有 uuid 或 file + const hasUUIDorFile = useMemo(() => { + return !!(searchParams.get("uuid") || searchParams.get("file")); + }, [searchParams]); + + const urlHref = useMemo(() => { + try { + if (!hasUUIDorFile) { + const url = searchParams.get("url"); + if (url) { + const urlObject = new URL(url); + if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { + return urlObject.href; + } + } + } + } catch { + // ignored + } + return ""; + }, [hasUUIDorFile, searchParams]); + + const [fetchingState, setFetchingState] = useState({ + loadingStatus: "", + errorStatus: "", + }); + + const loadURLAsync = async (urlHref: string) => { + try { + const { code, metadata } = await fetchScriptBody(urlHref, { + onProgress: (info: { receivedLength: number }) => { + setFetchingState((prev) => ({ + ...prev, + loadingStatus: t("downloading_status_text", { bytes: `${formatBytes(info.receivedLength)}` }), + })); + }, + }); + const update = false; + const uuid = uuidv4(); + const url = urlHref; + const upsertBy = "user"; + + const si = [update, createScriptInfo(uuid, code, url, upsertBy, metadata)]; + await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, si); + setSearchParams( + (prev) => { + prev.delete("url"); + prev.set("uuid", uuid); + return prev; + }, + { replace: true } + ); + } catch (err: any) { + const errMessage = `${err.message || err}`; + setFetchingState((prev) => ({ + ...prev, + loadingStatus: "", + errorStatus: errMessage, + })); + } + }; + + useEffect(() => { + if (!urlHref) return; + loadURLAsync(urlHref); + }, [urlHref]); + + if (!hasUUIDorFile) { + return urlHref ? ( +
+ + {t("install_page_loading")} + {fetchingState.loadingStatus && ( +
+ {fetchingState.loadingStatus} +
-
- - {`${t("source")}: ${scriptInfo?.url}`} + )} + {fetchingState.errorStatus &&
{fetchingState.errorStatus}
} + +
+ ) : ( +
+ + {t("invalid_page")} + +
+ ); + } + + return ( +
+
+
+ {upsertScript?.metadata.icon && ( + + {upsertScript.name} + + )} + {upsertScript && ( + + + {i18nName(upsertScript)} -
-
- - - - - - {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} - - {!scriptInfo?.userSubscribe && ( - - {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} - - )} - - } - position="bottom" - disabled={watchFile} - > - - - )} - {isUpdate ? ( - - - - {!scriptInfo?.userSubscribe && ( - - {t("close_update_script_no_more_update")} - - )} - - } - position="bottom" - > - - )} - -
- - - - -
- - {oldScriptVersion && ( - - {oldScriptVersion} - - )} - {metadataLive.version && metadataLive.version[0] !== oldScriptVersion && ( - - - {metadataLive.version[0]} - - - )} + + )} + + + +
+
+
+ {oldScriptVersion && ( + + {oldScriptVersion} + + )} + {metadataLive.version && metadataLive.version[0] !== oldScriptVersion && ( + + + {metadataLive.version[0]} + + + )} +
+
+
+
+
+
+
+
{(metadataLive.background || metadataLive.crontab) && ( @@ -597,7 +753,7 @@ function App() { )} - {metadataLive.antifeature && + {metadataLive.antifeature?.length && metadataLive.antifeature.map((antifeature) => { const item = antifeature.split(" ")[0]; return ( @@ -610,47 +766,143 @@ function App() { ) ); })} - +
+
+
+ {upsertScript && i18nDescription(upsertScript!)} +
+
+ {`${t("author")}: ${metadataLive.author}`} +
+
+ + {`${t("source")}: ${scriptInfo?.url}`} + +
+
- {description && description} -
- {t("install_from_legitimate_sources_warning")} +
+ {descriptionParagraph?.length ? ( +
+ + + {descriptionParagraph} + +
- - - - + ) : ( + <> + )} +
{permissions.map((item) => ( - - - {item.label} - - {item.value.map((v) => ( -
- {v} -
- ))} -
+
+ {item.value?.length > 0 ? ( + <> + + {item.label} + +
+ {item.value.map((v) => ( +
+ {v} +
+ ))} +
+ + ) : ( + <> + )} +
))} - - - -
- +
+
+
+
+ {t("install_from_legitimate_sources_warning")} +
+
+ + + + + + {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} + + {!scriptInfo?.userSubscribe && ( + + {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} + + )} + + } + position="bottom" + disabled={watchFile} + > + + + )} + {isUpdate ? ( + + + + {!scriptInfo?.userSubscribe && ( + + {t("close_update_script_no_more_update")} + + )} + + } + position="bottom" + > + + )} + +
+
+
+ +
); diff --git a/src/pages/install/index.css b/src/pages/install/index.css index 5b90b58fd..58a8e6797 100644 --- a/src/pages/install/index.css +++ b/src/pages/install/index.css @@ -2,15 +2,6 @@ background: var(--vscode-editorGutter-background); } -:root { - --show-code-height: 100vh; - --show-code-height: min(calc(100vh), calc(40vh + 360px)); /* 可設為 400px */ /* 可設為 100vh */ -} - -.install-main-layout { - min-height: max-content; -} - #install-app-container { display: flex; flex-direction: column; @@ -20,15 +11,15 @@ #show-code-container { display: block; - height: var(--show-code-height); - padding: 16px 0px; + height: calc( 100% - 44px ); + padding: 2px 2px; position: relative; box-sizing: border-box; margin: 0; border: 0; flex-grow: 1; - flex-shrink: 0; - contain: strict; + flex-shrink: 0; + contain: strict; } #show-code { @@ -43,3 +34,77 @@ position: relative; background: #071119; } + +.downloading { + display: flex; + flex-direction: row; + flex-wrap: wrap; + column-gap: 4px; + align-items: center; +} + +.error-message { + color: red; +} + +.error-message:empty { + display: none; +} + +.error-message::before { + content: "ERROR: "; +} + +/* https://css-loaders.com/dots/ */ +.loader { + width: 60px; + aspect-ratio: 2; + --_g: no-repeat radial-gradient(circle closest-side, currentColor 90%, rgba(0,0,0,0)); + background: var(--_g) 0% 50%, var(--_g) 50% 50%, var(--_g) 100% 50%; + background-size: calc(100%/3) 50%; + animation: l3 1s infinite linear; + transform: scale(0.5); +} + +@keyframes l3 { + 20% { + background-position: 0% 0%, 50% 50%, 100% 50% + } + + 40% { + background-position: 0% 100%, 50% 0%, 100% 50% + } + + 60% { + background-position: 0% 50%, 50% 100%, 100% 0% + } + + 80% { + background-position: 0% 50%, 50% 50%, 100% 100% + } + +} + +.tag-container { + inline-size: min-content; + align-content: flex-start; + justify-items: flex-end; + justify-content: flex-end; +} + +.tag-container .arco-tag { + flex-grow: 1; + text-align: center; +} + +div.permission-entry span.arco-typography { + line-height: 1rem; + padding: 0; + margin: 0; +} + +div.permission-entry { + line-height: 1.2rem; + padding: 0; + margin: 0; +} diff --git a/src/pages/install/main.tsx b/src/pages/install/main.tsx index d20b8a28e..8953bb657 100644 --- a/src/pages/install/main.tsx +++ b/src/pages/install/main.tsx @@ -11,6 +11,7 @@ import "@App/locales/locales"; import "@App/index.css"; import "./index.css"; import registerEditor from "@App/pkg/utils/monaco-editor"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; registerEditor(); @@ -22,13 +23,20 @@ const loggerCore = new LoggerCore({ loggerCore.logger().debug("install page start"); -const Root = ( +const MyApp = () => ( - + ); +const Root = ( + + + } /> + + +); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( process.env.NODE_ENV === "development" ? {Root} : Root diff --git a/src/pkg/utils/utils.test.ts b/src/pkg/utils/utils.test.ts index 4f60ad633..77613a1e7 100644 --- a/src/pkg/utils/utils.test.ts +++ b/src/pkg/utils/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeAll } from "vitest"; -import { aNow, checkSilenceUpdate, cleanFileName, stringMatching, toCamelCase } from "./utils"; +import { aNow, checkSilenceUpdate, cleanFileName, formatBytes, stringMatching, toCamelCase } from "./utils"; import { ltever, versionCompare } from "@App/pkg/utils/semver"; import { nextTime } from "./cron"; import dayjs from "dayjs"; @@ -373,3 +373,28 @@ describe.concurrent("toCamelCase", () => { expect(toCamelCase("script_list_column_width")).toBe("ScriptListColumnWidth"); }); }); + +describe.concurrent("formatBytes", () => { + it.concurrent("应当正确格式化字节大小", () => { + // 0 字节 + expect(formatBytes(0)).toBe("0 B"); + + // 字节单位 + expect(formatBytes(100)).toBe("100.00 B"); + expect(formatBytes(512)).toBe("512.00 B"); + + // KB 单位 + expect(formatBytes(1024)).toBe("1.00 KB"); + expect(formatBytes(2048)).toBe("2.00 KB"); + expect(formatBytes(1536)).toBe("1.50 KB"); + + // MB 单位 + expect(formatBytes(1048576)).toBe("1.00 MB"); + expect(formatBytes(2097152)).toBe("2.00 MB"); + expect(formatBytes(1234567)).toBe("1.18 MB"); + + // 自定义小数位数 + expect(formatBytes(1536, 0)).toBe("2 KB"); + expect(formatBytes(1536, 1)).toBe("1.5 KB"); + }); +}); diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index f24ff5547..cac004526 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -417,3 +417,20 @@ export const stringMatching = (main: string, sub: string): boolean => { return false; } }; + +/** + * 将字节数转换为人类可读的格式(B, KB, MB, GB 等)。 + * @param bytes - 要转换的字节数(number)。 + * @param decimals - 小数位数,默认为 2。 + * @returns 格式化的字符串,例如 "1.23 MB"。 + */ +export const formatBytes = (bytes: number, decimals: number = 2): string => { + if (bytes === 0) return "0 B"; + + const k = 1024; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const value = bytes / Math.pow(k, i); + + return `${value.toFixed(decimals)} ${units[i]}`; +};