diff --git a/package.json b/package.json index fa7228c66..ebf1b80f8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "eventemitter3": "^5.0.1", "i18next": "^23.16.4", "monaco-editor": "^0.52.2", + "punycode": "^2.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5012eaaec..7ceb42212 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: monaco-editor: specifier: ^0.52.2 version: 0.52.2 + punycode: + specifier: ^2.3.1 + version: 2.3.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -982,56 +985,67 @@ packages: resolution: {integrity: sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.44.2': resolution: {integrity: sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.44.2': resolution: {integrity: sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.44.2': resolution: {integrity: sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.44.2': resolution: {integrity: sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.44.2': resolution: {integrity: sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.44.2': resolution: {integrity: sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.44.2': resolution: {integrity: sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.44.2': resolution: {integrity: sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.44.2': resolution: {integrity: sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.44.2': resolution: {integrity: sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.44.2': resolution: {integrity: sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==} @@ -1062,21 +1076,25 @@ packages: resolution: {integrity: sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@1.7.6': resolution: {integrity: sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@1.7.6': resolution: {integrity: sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@1.7.6': resolution: {integrity: sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-wasm32-wasi@1.7.6': resolution: {integrity: sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==} diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index cf254e349..7b984f867 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -325,8 +325,10 @@ "header_other_update": "crwdns12784:0crwdne12784:0" }, "downloading_status_text": "Downloading. Received {{bytes}}.", - "install_page_loading": "Installation page loading", - "invalid_page": "Invalid page", + "install_page_please_wait": "Please wait", + "install_page_loading": "Loading Installation Page", + "install_page_load_failed": "Failed to Load Installation Page", + "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 6ea643df8..dc389e7ea 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -334,7 +334,9 @@ "header_other_update": "Andere verfügbare Updates" }, "downloading_status_text": "Wird heruntergeladen. {{bytes}} empfangen.", + "install_page_please_wait": "Bitte warten Sie", "install_page_loading": "Installationsseite wird geladen", + "install_page_load_failed": "Installationsseite konnte nicht geladen werden", "invalid_page": "Ungültige Seite", "background_script_tag": "Dies ist ein Hintergrundskript", "scheduled_script_tag": "Dies ist ein geplantes Skript", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 1091314dd..006a3af2d 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -334,8 +334,10 @@ "header_other_update": "Other available updates" }, "downloading_status_text": "Downloading. Received {{bytes}}.", - "install_page_loading": "Installation page loading", - "invalid_page": "Invalid page", + "install_page_please_wait": "Please wait", + "install_page_loading": "Loading Installation Page", + "install_page_load_failed": "Failed to Load Installation Page", + "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 346ac1524..68a3db516 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -334,7 +334,9 @@ "header_other_update": "その他の利用可能な更新" }, "downloading_status_text": "ダウンロード中。{{bytes}} を受信しました。", + "install_page_please_wait": "しばらくお待ちください", "install_page_loading": "インストールページを読み込み中", + "install_page_load_failed": "インストールページの読み込みに失敗しました", "invalid_page": "無効なページ", "background_script_tag": "これはバックグラウンドスクリプトです", "scheduled_script_tag": "これはスケジュールスクリプトです", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index cc80fe49e..f3da00dc2 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -334,7 +334,9 @@ "header_other_update": "Другие доступные обновления" }, "downloading_status_text": "Загрузка. Получено {{bytes}}.", + "install_page_please_wait": "Пожалуйста, подождите", "install_page_loading": "Загрузка страницы установки", + "install_page_load_failed": "Не удалось загрузить страницу установки", "invalid_page": "Недействительная страница", "background_script_tag": "Это фоновый скрипт", "scheduled_script_tag": "Это запланированный скрипт", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index f16febc99..ed482f3ed 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -334,7 +334,9 @@ "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_please_wait": "Vui lòng chờ", "install_page_loading": "Đang tải trang cài đặt", + "install_page_load_failed": "Tải trang cài đặt thất bại", "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ờ", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 508c9ea3d..80be7c663 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -334,7 +334,9 @@ "header_other_update": "其他可用更新" }, "downloading_status_text": "正在下载。已接收 {{bytes}}。", + "install_page_please_wait": "请稍等", "install_page_loading": "安装页面加载中", + "install_page_load_failed": "安装页面加载失败", "invalid_page": "无效页面", "background_script_tag": "这是一个后台脚本", "scheduled_script_tag": "这是一个定时脚本", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index abb5c289d..c384e2ab4 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -334,7 +334,9 @@ "header_other_update": "其他可用更新" }, "downloading_status_text": "正在下載。已接收 {{bytes}}。", - "install_page_loading": "安裝頁載入中", + "install_page_please_wait": "請稍等", + "install_page_loading": "安裝頁面載入中", + "install_page_load_failed": "安裝頁面載入失敗", "invalid_page": "無效頁面", "background_script_tag": "這是一個背景腳本", "scheduled_script_tag": "這是一個排程腳本", diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index d27481a9c..b481e74d6 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -31,9 +31,10 @@ 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, prettyUrl } from "@App/pkg/utils/utils"; +import { formatBytes } from "@App/pkg/utils/utils"; import { ScriptIcons } from "../options/routes/utils"; import { bytesDecode, detectEncoding } from "@App/pkg/utils/encoding"; +import { prettyUrl } from "@App/pkg/utils/url-utils"; const backgroundPromptShownKey = "background_prompt_shown"; @@ -48,8 +49,8 @@ interface PermissionItem { type Permission = PermissionItem[]; -const closeWindow = (doBackwards: boolean) => { - if (doBackwards) { +const closeWindow = (shouldGoBack: boolean) => { + if (shouldGoBack) { history.go(-1); } else { window.close(); @@ -72,7 +73,7 @@ const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any "Accept-Encoding": "br;q=1.0, gzip;q=0.8, *;q=0.1", Origin: origin, }, - referrer: origin + "/", + referrer: `${origin}/`, }); if (!response.ok) { @@ -133,43 +134,43 @@ const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any return { code, metadata }; }; -const cleanupStaleInstallInfo = (uuid: string) => { +const cleanupStaleInstallInfo = (scriptUuid: string) => { // 页面打开时不清除当前uuid,每30秒更新一次记录 - const f = () => { + const updateKeepAlive = () => { cacheInstance.tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { val = val || {}; - val[uuid] = Date.now(); + val[scriptUuid] = Date.now(); tx.set(val); }); }; - f(); - setInterval(f, 30_000); + updateKeepAlive(); + setInterval(updateKeepAlive, 30_000); // 页面打开后清除旧记录 const delay = Math.floor(5000 * Math.random()) + 10000; // 使用随机时间避免浏览器重启时大量Tabs同时执行清除 timeoutExecution( - `${cIdKey}cleanupStaleInstallInfo`, + `${componentInstanceId}cleanupStaleInstallInfo`, () => { cacheInstance .tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { const now = Date.now(); - const keeps = new Set(); - const out: Record = {}; + const activeKeepKeys = new Set(); + const updatedRegistry: 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; + activeKeepKeys.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); + updatedRegistry[k] = ts; } } - tx.set(out); - return keeps; + tx.set(updatedRegistry); + return activeKeepKeys; }) .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) { + const allCacheKeys = await cacheInstance.list(); + const keysToPurge = allCacheKeys.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); + if (keysToPurge.length) { // 清理缓存 - cacheInstance.dels(filtered); + cacheInstance.dels(keysToPurge); } }); }, @@ -177,34 +178,34 @@ const cleanupStaleInstallInfo = (uuid: string) => { ); }; -const cIdKey = `(cid_${Math.random()})`; +const componentInstanceId = `(cid_${Math.random()})`; function App() { - const [enable, setEnable] = useState(false); - const [btnText, setBtnText] = useState(""); - const [scriptCode, setScriptCode] = useState(""); - const [scriptInfo, setScriptInfo] = useState(); - const [upsertScript, setUpsertScript] = useState(undefined); - const [diffCode, setDiffCode] = useState(); - const [oldScriptVersion, setOldScriptVersion] = useState(null); - const [isUpdate, setIsUpdate] = useState(false); + const [isScriptEnabled, setIsScriptEnabled] = useState(false); + const [installButtonText, setInstallButtonText] = useState(""); + const [currentScriptCode, setCurrentScriptCode] = useState(""); + const [scriptInstallConfig, setScriptInstallConfig] = useState(); + const [pendingScript, setPendingScript] = useState(undefined); + const [diffBaseCode, setDiffBaseCode] = useState(); + const [installedVersion, setInstalledVersion] = useState(null); + const [isUpdateMode, setIsUpdateMode] = useState(false); const [localFileHandle, setLocalFileHandle] = useState(null); const [showBackgroundPrompt, setShowBackgroundPrompt] = useState(false); const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); - const [loaded, setLoaded] = useState(false); - const [doBackwards, setDoBackwards] = useState(false); + const [isPageLoaded, setIsPageLoaded] = useState(false); + const [shouldNavigateBack, setShouldNavigateBack] = useState(false); const installOrUpdateScript = async (newScript: Script, code: string) => { if (newScript.ignoreVersion) newScript.ignoreVersion = ""; await scriptClient.install({ script: newScript, code }); const metadata = newScript.metadata; - setScriptInfo((prev) => (prev ? { ...prev, code, metadata } : prev)); + setScriptInstallConfig((prev) => (prev ? { ...prev, code, metadata } : prev)); const scriptVersion = metadata.version?.[0]; - const oldScriptVersion = typeof scriptVersion === "string" ? scriptVersion : "N/A"; - setOldScriptVersion(oldScriptVersion); - setUpsertScript(newScript); - setDiffCode(code); + const versionStr = typeof scriptVersion === "string" ? scriptVersion : "N/A"; + setInstalledVersion(versionStr); + setPendingScript(newScript); + setDiffBaseCode(code); }; const getUpdatedNewScript = async (uuid: string, code: string) => { @@ -220,11 +221,11 @@ function App() { return script; }; - const initAsync = async () => { + const initializeInstallation = async () => { try { const uuid = searchParams.get("uuid"); const fid = searchParams.get("file"); - let info: ScriptInfo | undefined; + let installInfo: ScriptInfo | undefined; let isKnownUpdate: boolean = false; // 如果没有 uuid 和 file,跳过初始化逻辑 @@ -233,18 +234,18 @@ function App() { } if (window.history.length > 1) { - setDoBackwards(true); + setShouldNavigateBack(true); } - setLoaded(true); + setIsPageLoaded(true); let paramOptions = {}; if (uuid) { - const cachedInfo = await scriptClient.getInstallInfo(uuid); + const cachedData = await scriptClient.getInstallInfo(uuid); cleanupStaleInstallInfo(uuid); - if (cachedInfo?.[0]) isKnownUpdate = true; - info = cachedInfo?.[1] || undefined; - paramOptions = cachedInfo?.[2] || {}; - if (!info) { + if (cachedData?.[0]) isKnownUpdate = true; + installInfo = cachedData?.[1] || undefined; + paramOptions = cachedData?.[2] || {}; + if (!installInfo) { throw new Error("fetch script info failed"); } } else { @@ -269,136 +270,139 @@ function App() { // 刷新 timestamp, 使 10s~15s 后不会被立即清掉 // 每五分钟刷新一次db记录的timestamp,使开启中的安装页面的fileHandle不会被刷掉 - intervalExecution(`${cIdKey}liveFileHandle`, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); + const key = `${componentInstanceId}liveFileHandle`; + intervalExecution(key, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); const code = await file.text(); const metadata = parseMetadata(code); if (!metadata) { throw new Error("parse script info failed"); } - info = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); + installInfo = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); } - let prepare: + let preparationResult: | { script: Script; oldScript?: Script; oldScriptCode?: string } | { subscribe: Subscribe; oldSubscribe?: Subscribe }; - let action: Script | Subscribe; - - const { code, url } = info; - let oldVersion: string | undefined = undefined; - let diffCode: string | undefined = undefined; - if (info.userSubscribe) { - prepare = await prepareSubscribeByCode(code, url); - action = prepare.subscribe; - if (prepare.oldSubscribe) { - const oldSubscribeVersion = prepare.oldSubscribe.metadata.version?.[0]; - oldVersion = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; + let finalActionObject: Script | Subscribe; + + const { code, url } = installInfo; + let oldVersionStr: string | undefined = undefined; + let baseDiffCode: string | undefined = undefined; + if (installInfo.userSubscribe) { + preparationResult = await prepareSubscribeByCode(code, url); + finalActionObject = preparationResult.subscribe; + if (preparationResult.oldSubscribe) { + const oldSubscribeVersion = preparationResult.oldSubscribe.metadata.version?.[0]; + oldVersionStr = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; } - diffCode = prepare.oldSubscribe?.code; + baseDiffCode = preparationResult.oldSubscribe?.code; } else { - const knownUUID = isKnownUpdate ? info.uuid : undefined; - prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); - action = prepare.script; - if (prepare.oldScript) { - const oldScriptVersion = prepare.oldScript.metadata.version?.[0]; - oldVersion = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; + const knownUUID = isKnownUpdate ? installInfo.uuid : undefined; + preparationResult = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); + finalActionObject = preparationResult.script; + if (preparationResult.oldScript) { + const oldScriptVersion = preparationResult.oldScript.metadata.version?.[0]; + oldVersionStr = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; } - diffCode = prepare.oldScriptCode; + baseDiffCode = preparationResult.oldScriptCode; } - setScriptCode(code); - setDiffCode(diffCode); - setOldScriptVersion(typeof oldVersion === "string" ? oldVersion : null); - setIsUpdate(typeof oldVersion === "string"); - setScriptInfo(info); - setUpsertScript(action); + setCurrentScriptCode(code); + setDiffBaseCode(baseDiffCode); + setInstalledVersion(typeof oldVersionStr === "string" ? oldVersionStr : null); + setIsUpdateMode(typeof oldVersionStr === "string"); + setScriptInstallConfig(installInfo); + setPendingScript(finalActionObject); // 检查是否需要显示后台运行提示 - if (!info.userSubscribe) { - setShowBackgroundPrompt(await checkBackgroundPrompt(action as Script)); + if (!installInfo.userSubscribe) { + setShowBackgroundPrompt(await checkBackgroundPrompt(finalActionObject as Script)); } } catch (e: any) { - Message.error(t("script_info_load_failed") + " " + e.message); + Message.error(`${t("script_info_load_failed")} ${e?.message ?? e}`); } finally { // fileHandle 保留处理方式(暂定): // fileHandle 会保留一段足够时间,避免用户重新刷画面,重启浏览器等操作后,安装页变得空白一片。 // 处理会在所有Tab都载入后(不包含睡眠Tab)进行,因此延迟 10s~15s 让处理有足够时间。 // 安装页面关掉后15分钟为不保留状态,会在安装画面再次打开时(其他脚本安装),进行清除。 - const delay = Math.floor(5000 * Math.random()) + 10000; // 使用乱数时间避免浏览器重启时大量Tabs同时执行DB清除 - timeoutExecution(`${cIdKey}cleanupFileHandle`, cleanupOldHandles, delay); + const randomDelay = Math.floor(5000 * Math.random()) + 10000; // 使用乱数时间避免浏览器重启时大量Tabs同时执行DB清除 + timeoutExecution(`${componentInstanceId}cleanupFileHandle`, cleanupOldHandles, randomDelay); } }; + // 有 file 或 uuid 时加载安装画面 useEffect(() => { - !loaded && initAsync(); - }, [searchParams, loaded]); + !isPageLoaded && initializeInstallation(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams.get("uuid"), searchParams.get("file"), isPageLoaded]); - const [watchFile, setWatchFile] = useState(false); - const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); + const [isFileWatchingEnabled, setIsFileWatchingEnabled] = useState(false); + const liveMetadata = useMemo(() => (scriptInstallConfig?.metadata || {}) as SCMetadata, [scriptInstallConfig]); - const permissions = useMemo(() => { + const scriptPermissions = useMemo(() => { const permissions: Permission = []; - if (!scriptInfo) return permissions; + if (!scriptInstallConfig) return permissions; - if (scriptInfo.userSubscribe) { + if (scriptInstallConfig.userSubscribe) { permissions.push({ label: t("subscribe_install_label"), color: "#ff0000", - value: metadataLive.scripturl!, + value: liveMetadata.scripturl!, }); } - if (metadataLive.match) { - permissions.push({ label: t("script_runs_in"), value: metadataLive.match }); + if (liveMetadata.match) { + permissions.push({ label: t("script_runs_in"), value: liveMetadata.match }); } - if (metadataLive.connect) { + if (liveMetadata.connect) { permissions.push({ label: t("script_has_full_access_to"), color: "#F9925A", - value: metadataLive.connect, + value: liveMetadata.connect, }); } - if (metadataLive.require) { - permissions.push({ label: t("script_requires"), value: metadataLive.require }); + if (liveMetadata.require) { + permissions.push({ label: t("script_requires"), value: liveMetadata.require }); } return permissions; - }, [scriptInfo, metadataLive, t]); + }, [scriptInstallConfig, liveMetadata, t]); - const descriptionParagraph = useMemo(() => { - const ret: JSX.Element[] = []; + const descriptionParagraphs = useMemo(() => { + const elements: JSX.Element[] = []; - if (!scriptInfo) return ret; + if (!scriptInstallConfig) return elements; - const isCookie = metadataLive.grant?.some((val) => val === "GM_cookie"); - if (isCookie) { - ret.push( + const hasCookieGrant = liveMetadata.grant?.some((val) => val === "GM_cookie"); + if (hasCookieGrant) { + elements.push( {t("cookie_warning")} ); } - if (metadataLive.crontab) { - ret.push({t("scheduled_script_description_title")}); - ret.push( + if (liveMetadata.crontab) { + elements.push({t("scheduled_script_description_title")}); + elements.push(
{t("scheduled_script_description_description_expr")} - {metadataLive.crontab[0]} + {liveMetadata.crontab[0]} {t("scheduled_script_description_description_next")} - {nextTimeDisplay(metadataLive.crontab[0])} + {nextTimeDisplay(liveMetadata.crontab[0])}
); - } else if (metadataLive.background) { - ret.push({t("background_script_description")}); + } else if (liveMetadata.background) { + elements.push({t("background_script_description")}); } - return ret; - }, [scriptInfo, metadataLive, t]); + return elements; + }, [scriptInstallConfig, liveMetadata, t]); - const antifeatures: { [key: string]: { color: string; title: string; description: string } } = { + const antifeatureRegistry: { [key: string]: { color: string; title: string; description: string } } = { "referral-link": { color: "purple", title: t("antifeature_referral_link_title"), @@ -433,22 +437,22 @@ function App() { // 更新按钮文案和页面标题 useEffect(() => { - if (scriptInfo?.userSubscribe) { - setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe")); + if (scriptInstallConfig?.userSubscribe) { + setInstallButtonText(isUpdateMode ? t("update_subscribe")! : t("install_subscribe")); } else { - setBtnText(isUpdate ? t("update_script")! : t("install_script")); + setInstallButtonText(isUpdateMode ? t("update_script")! : t("install_script")); } - if (upsertScript) { - document.title = `${!isUpdate ? t("install_script") : t("update_script")} - ${i18nName(upsertScript!)} - ScriptCat`; + if (pendingScript) { + document.title = `${!isUpdateMode ? t("install_script") : t("update_script")} - ${i18nName(pendingScript!)} - ScriptCat`; } - }, [isUpdate, scriptInfo, upsertScript, t]); + }, [isUpdateMode, scriptInstallConfig, pendingScript, t]); // 设置脚本状态 useEffect(() => { - if (upsertScript) { - setEnable(upsertScript.status === SCRIPT_STATUS_ENABLE); + if (pendingScript) { + setIsScriptEnabled(pendingScript.status === SCRIPT_STATUS_ENABLE); } - }, [upsertScript]); + }, [pendingScript]); // 检查是否需要显示后台运行提示 const checkBackgroundPrompt = async (script: Script) => { @@ -469,8 +473,8 @@ function App() { return false; }; - const handleInstall = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { - if (!upsertScript) { + const executeInstallation = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { + if (!pendingScript) { Message.error(t("script_info_load_failed")!); return; } @@ -478,57 +482,57 @@ function App() { const { closeAfterInstall: shouldClose = true, noMoreUpdates: disableUpdates = false } = options; try { - if (scriptInfo?.userSubscribe) { - await subscribeClient.install(upsertScript as Subscribe); + if (scriptInstallConfig?.userSubscribe) { + await subscribeClient.install(pendingScript as Subscribe); Message.success(t("subscribe_success")!); - setBtnText(t("subscribe_success")!); + setInstallButtonText(t("subscribe_success")!); } else { // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { + if (disableUpdates && pendingScript) { // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; + (pendingScript as Script).checkUpdate = false; } // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { + await scriptClient.install({ script: pendingScript as Script, code: currentScriptCode }); + if (isUpdateMode) { Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); + setInstallButtonText(t("install.update_success")!); } else { // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { + if (disableUpdates && pendingScript) { // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; + (pendingScript as Script).checkUpdate = false; } - if ((upsertScript as Script).ignoreVersion) (upsertScript as Script).ignoreVersion = ""; + if ((pendingScript as Script).ignoreVersion) (pendingScript as Script).ignoreVersion = ""; // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { + await scriptClient.install({ script: pendingScript as Script, code: currentScriptCode }); + if (isUpdateMode) { Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); + setInstallButtonText(t("install.update_success")!); } else { Message.success(t("install_success")!); - setBtnText(t("install_success")!); + setInstallButtonText(t("install_success")!); } } } if (shouldClose) { setTimeout(() => { - closeWindow(doBackwards); + closeWindow(shouldNavigateBack); }, 500); } } catch (e) { - const errorMessage = scriptInfo?.userSubscribe ? t("subscribe_failed") : t("install_failed"); + const errorMessage = scriptInstallConfig?.userSubscribe ? t("subscribe_failed") : t("install_failed"); Message.error(`${errorMessage}: ${e}`); } }; - const handleClose = (options?: { noMoreUpdates: boolean }) => { + const handlePageClose = (options?: { noMoreUpdates: boolean }) => { const { noMoreUpdates = false } = options || {}; - if (noMoreUpdates && scriptInfo && !scriptInfo.userSubscribe) { - scriptClient.setCheckUpdateUrl(scriptInfo.uuid, false); + if (noMoreUpdates && scriptInstallConfig && !scriptInstallConfig.userSubscribe) { + scriptClient.setCheckUpdateUrl(scriptInstallConfig.uuid, false); } - closeWindow(doBackwards); + closeWindow(shouldNavigateBack); }; const { @@ -538,45 +542,45 @@ function App() { handleStatusChange, handleCloseBasic, handleCloseNoMoreUpdates, - setWatchFileClick, + toggleFileWatch, } = { - handleInstallBasic: () => handleInstall(), - handleInstallCloseAfterInstall: () => handleInstall({ closeAfterInstall: false }), - handleInstallNoMoreUpdates: () => handleInstall({ noMoreUpdates: true }), + handleInstallBasic: () => executeInstallation(), + handleInstallCloseAfterInstall: () => executeInstallation({ closeAfterInstall: false }), + handleInstallNoMoreUpdates: () => executeInstallation({ noMoreUpdates: true }), handleStatusChange: (checked: boolean) => { - setUpsertScript((script) => { + setPendingScript((script) => { if (!script) { return script; } script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; - setEnable(checked); + setIsScriptEnabled(checked); return script; }); }, - handleCloseBasic: () => handleClose(), - handleCloseNoMoreUpdates: () => handleClose({ noMoreUpdates: true }), - setWatchFileClick: () => { - setWatchFile((prev) => !prev); + handleCloseBasic: () => handlePageClose(), + handleCloseNoMoreUpdates: () => handlePageClose({ noMoreUpdates: true }), + toggleFileWatch: () => { + setIsFileWatchingEnabled((prev) => !prev); }, }; const fileWatchMessageId = `id_${Math.random()}`; async function onWatchFileCodeChanged(this: FTInfo, code: string, hideInfo: boolean = false) { - if (this.uuid !== scriptInfo?.uuid) return; + if (this.uuid !== scriptInstallConfig?.uuid) return; if (this.fileName !== localFileHandle?.name) return; - setScriptCode(code); - const uuid = (upsertScript as Script)?.uuid; + setCurrentScriptCode(code); + const uuid = (pendingScript as Script)?.uuid; if (!uuid) { throw new Error("uuid is undefined"); } try { const newScript = await getUpdatedNewScript(uuid, code); await installOrUpdateScript(newScript, code); - } catch (e) { + } catch (e: any) { Message.error({ id: fileWatchMessageId, - content: t("install_failed") + ": " + e, + content: `${t("install_failed")}: ${e?.message ?? e}`, }); return; } @@ -593,21 +597,19 @@ function App() { async function onWatchFileError() { // e.g. NotFoundError - setWatchFile(false); + setIsFileWatchingEnabled(false); } - const memoWatchFile = useMemo(() => { - return `${watchFile}.${scriptInfo?.uuid}.${localFileHandle?.name}`; - }, [watchFile, scriptInfo, localFileHandle]); + const watchFileStateIdentifier = `${isFileWatchingEnabled}.${scriptInstallConfig?.uuid}.${localFileHandle?.name}`; const setupWatchFile = async (uuid: string, fileName: string, handle: FileSystemFileHandle) => { try { // 如没有安装纪录,将进行安装。 // 如已经安装,在FileSystemObserver检查更改前,先进行更新。 - const code = `${scriptCode}`; - await installOrUpdateScript(upsertScript as Script, code); + const code = `${currentScriptCode}`; + await installOrUpdateScript(pendingScript as Script, code); // setScriptCode(`${code}`); - setDiffCode(`${code}`); + setDiffBaseCode(`${code}`); const ftInfo: FTInfo = { uuid, fileName, @@ -629,14 +631,14 @@ function App() { } }; - useEffect(() => { - if (!watchFile || !localFileHandle) { + const handleFileWatchChange = () => { + if (!isFileWatchingEnabled || !localFileHandle) { return; } // 去除React特性 const [handle] = [localFileHandle]; unmountFileTrack(handle); // 避免重复追踪 - const uuid = scriptInfo?.uuid; + const uuid = scriptInstallConfig?.uuid; const fileName = handle?.name; if (!uuid || !fileName) { return; @@ -645,52 +647,67 @@ function App() { return () => { unmountFileTrack(handle); }; - }, [memoWatchFile]); + }; - // 检查是否有 uuid 或 file - const hasUUIDorFile = useMemo(() => { - return !!(searchParams.get("uuid") || searchParams.get("file")); - }, [searchParams]); + // 当 watch file 启用时,用于追踪本地 file 更新 + useEffect(() => { + handleFileWatchChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watchFileStateIdentifier]); - 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; - } + // 检查是否有 uuid 或 file + const hasValidSourceParam = !!(searchParams.get("uuid") || searchParams.get("file")); + + const targetUrlHref = useMemo(() => { + if (!hasValidSourceParam) { + let url; + try { + // 取url=之后的所有内容 + url = location.search.match(/\?url=([^&]+)/)?.[1] || ""; + } catch { + // ignored + } + if (!url) { + return ""; + } + try { + const urlObject = new URL(url); + // 验证解析后的 URL 是否具备核心要素,确保安全性与合法性 + if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { + return url; } + } catch { + // ignored } - } catch { - // ignored } return ""; - }, [hasUUIDorFile, searchParams]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasValidSourceParam, searchParams.get("url")]); const [fetchingState, setFetchingState] = useState({ - loadingStatus: "", - errorStatus: "", + loadingStatusText: "", + errorStatusText: "", }); const loadURLAsync = async (urlHref: string) => { try { + // 2. 执行获取 const { code, metadata } = await fetchScriptBody(urlHref, { onProgress: (info: { receivedLength: number }) => { setFetchingState((prev) => ({ ...prev, - loadingStatus: t("downloading_status_text", { bytes: `${formatBytes(info.receivedLength)}` }), + loadingStatusText: t("downloading_status_text", { bytes: `${formatBytes(info.receivedLength)}` }), })); }, }); - const update = false; + + // 3. 处理数据与缓存 const uuid = uuidv4(); - const url = urlHref; - const upsertBy = "user"; + const scriptData = [false, createScriptInfo(uuid, code, urlHref, "user", metadata)]; - const si = [update, createScriptInfo(uuid, code, url, upsertBy, metadata)]; - await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, si); + await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, scriptData); + + // 4. 更新导向 setSearchParams( (prev) => { prev.delete("url"); @@ -700,32 +717,40 @@ function App() { { replace: true } ); } catch (err: any) { - const errMessage = `${err.message || err}`; + // 5. 统一错误处理 setFetchingState((prev) => ({ ...prev, - loadingStatus: "", - errorStatus: errMessage, + loadingStatusText: "", + errorStatusText: String(err?.message || err), })); } }; + // 有 url 的话下载内容 useEffect(() => { - if (!urlHref) return; - loadURLAsync(urlHref); - }, [urlHref]); + if (!targetUrlHref) return; + loadURLAsync(targetUrlHref); + }, [targetUrlHref]); - if (!hasUUIDorFile) { - return urlHref ? ( + if (!hasValidSourceParam) { + return targetUrlHref ? (
- {t("install_page_loading")} - {fetchingState.loadingStatus && ( -
- {fetchingState.loadingStatus} -
-
+ {fetchingState.loadingStatusText && ( + <> + {t("install_page_loading")} +
+ {fetchingState.loadingStatusText} +
+
+ + )} + {fetchingState.errorStatusText && ( + <> + {t("install_page_load_failed")} +
{fetchingState.errorStatusText}
+ )} - {fetchingState.errorStatus &&
{fetchingState.errorStatus}
}
) : ( @@ -770,7 +795,7 @@ function App() { {t("enable_background.prompt_description", { - scriptType: upsertScript?.metadata?.background ? t("background_script") : t("scheduled_script"), + scriptType: pendingScript?.metadata?.background ? t("background_script") : t("scheduled_script"), })} {t("enable_background.settings_hint")} @@ -778,29 +803,31 @@ function App() {
- {upsertScript?.metadata.icon && } - {upsertScript && ( - + {pendingScript?.metadata.icon && } + {pendingScript && ( + - {i18nName(upsertScript)} + {i18nName(pendingScript)} )} - - + +
- {oldScriptVersion && ( - - {oldScriptVersion} + {installedVersion && ( + + {installedVersion} )} - {typeof metadataLive.version?.[0] === "string" && metadataLive.version[0] !== oldScriptVersion && ( - + {typeof liveMetadata.version?.[0] === "string" && liveMetadata.version[0] !== installedVersion && ( + - {metadataLive.version[0]} + {liveMetadata.version[0]} )} @@ -812,28 +839,31 @@ function App() {
- {(metadataLive.background || metadataLive.crontab) && ( + {(liveMetadata.background || liveMetadata.crontab) && ( {t("background_script")} )} - {metadataLive.crontab && ( + {liveMetadata.crontab && ( {t("scheduled_script")} )} - {metadataLive.antifeature?.length && - metadataLive.antifeature.map((antifeature) => { + {liveMetadata.antifeature?.length && + liveMetadata.antifeature.map((antifeature) => { const item = antifeature.split(" ")[0]; return ( - antifeatures[item] && ( - - - {antifeatures[item].title} + antifeatureRegistry[item] && ( + + + {antifeatureRegistry[item].title} ) @@ -842,10 +872,10 @@ function App() {
- {upsertScript && i18nDescription(upsertScript!)} + {pendingScript && i18nDescription(pendingScript!)}
- {`${t("author")}: ${metadataLive.author}`} + {`${t("author")}: ${liveMetadata.author}`}
- {`${t("source")}: ${prettyUrl(scriptInfo?.url)}`} + {`${t("source")}: ${prettyUrl(scriptInstallConfig?.url)}`}
- {descriptionParagraph?.length ? ( + {descriptionParagraphs?.length ? (
- {descriptionParagraph} + {descriptionParagraphs}
@@ -876,7 +906,7 @@ function App() { <> )}
- {permissions.map((item) => ( + {scriptPermissions.map((item) => (
{item.value?.length > 0 ? ( <> @@ -912,36 +942,36 @@ function App() {
- - {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} + {isUpdateMode ? t("update_script_no_close") : t("install_script_no_close")} - {!scriptInfo?.userSubscribe && ( + {!scriptInstallConfig?.userSubscribe && ( - {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} + {isUpdateMode ? t("update_script_no_more_update") : t("install_script_no_more_update")} )} } position="bottom" - disabled={watchFile} + disabled={isFileWatchingEnabled} > - )} - {isUpdate ? ( + {isUpdateMode ? (
diff --git a/src/pkg/utils/url-utils.test.ts b/src/pkg/utils/url-utils.test.ts new file mode 100644 index 000000000..d2e7c6e76 --- /dev/null +++ b/src/pkg/utils/url-utils.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from "vitest"; +import { prettyUrl } from "./url-utils"; // Update with your actual file path + +describe.concurrent("prettyUrl", () => { + describe.concurrent("Domain / Punycode Handling", () => { + it.concurrent("should decode CJK Punycode domains", () => { + expect(prettyUrl("http://xn--6qq79v.com")).toBe("http://你好.com/"); + }); + + it.concurrent("should decode Emoji domains", () => { + expect(prettyUrl("https://xn--vi8h.la/path")).toBe("https://🍕.la/path"); + }); + + it.concurrent("should handle mixed Latin and Foreign scripts", () => { + expect(prettyUrl("http://xn--maana-pta.com")).toBe("http://mañana.com/"); + }); + }); + + describe.concurrent("Path and Percent Encoding", () => { + it.concurrent("should decode CJK characters in the pathname", () => { + // %E6%B5%8B%E8%AF%95 -> 测试 + expect(prettyUrl("https://example.com/%E6%B5%8B%E8%AF%95")).toBe("https://example.com/测试"); + }); + + it.concurrent("should decode spaces and common symbols in path", () => { + expect(prettyUrl("https://site.com/hello%20world")).toBe("https://site.com/hello world"); + }); + + it.concurrent("should NOT decode if it.concurrent introduces reserved URL delimiters like ? or #", () => { + // If %3F (?) is decoded inside the path, it.concurrent breaks the URL structure + const input = "https://example.com/path%3Fquery"; + expect(prettyUrl(input)).toBe(input); + }); + }); + + describe.concurrent("Search and Hash Parameters", () => { + it.concurrent("should decode complex query strings while preserving & and =", () => { + const input = "https://google.com/search?q=%E4%BD%A0%E5%A5%BD&hl=zh"; + expect(prettyUrl(input)).toBe("https://google.com/search?q=你好&hl=zh"); + }); + + it.concurrent("should decode fragments (hashes)", () => { + expect(prettyUrl("https://wiki.org/Main#%E7%BB%93%E8%AE%BA")).toBe("https://wiki.org/Main#结论"); + }); + }); + + describe.concurrent("Edge Cases and Safety", () => { + it.concurrent("should return empty string for null/undefined", () => { + expect(prettyUrl(null as any)).toBe(""); + expect(prettyUrl(undefined)).toBe(""); + }); + + it.concurrent("should return the original string if it.concurrent is not a valid URL", () => { + const invalid = "not-a-url-at-all"; + expect(prettyUrl(invalid)).toBe(invalid); + }); + + it.concurrent("should handle ports correctly", () => { + expect(prettyUrl("http://localhost:8080/test")).toBe("http://localhost:8080/test"); + }); + + it.concurrent("should handle URLs with base URLs provided", () => { + expect(prettyUrl("/path?q=%E2%9C%85", "https://base.com")).toBe("https://base.com/path?q=✅"); + }); + + it.concurrent("should fail gracefully on malformed percent encoding", () => { + // %E4 is an incomplete sequence for a 3-byte UTF-8 char + const malformed = "https://example.com/%E4%BD"; + expect(prettyUrl(malformed)).toBe(malformed); + }); + }); + + describe.concurrent("Internationalization / Foreign Languages", () => { + it.concurrent("should handle RTL (Right-to-Left) scripts like Arabic", () => { + // xn--ngbo2ef is part of an Arabic domain string + expect(prettyUrl("http://xn--ngbo2ef.com/%D9%85%D8%B1%D8%AD%D8%A8%D8%A7")).toBe("http://بنده.com/مرحبا"); + }); + }); + + describe.concurrent("Idempotence", () => { + it.concurrent("should produce identical result when run twice", () => { + const input = "https://xn--6qq79v.com/%E6%B5%8B%E8%AF%95?q=%F0%9F%9A%80"; + const once = prettyUrl(input); + const twice = prettyUrl(once); + expect(twice).toBe(once); + }); + }); + + describe.concurrent("Encoded Structural Characters", () => { + it.concurrent("should not decode encoded slash in path", () => { + const input = "https://example.com/a%2Fb"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should not decode encoded ampersand in query value", () => { + const input = "https://example.com/?q=hello%26world"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should not decode encoded equals in query value", () => { + const input = "https://example.com/?q=a%3Db"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should not decode encoded hash in path", () => { + const input = "https://example.com/a%23b"; + expect(prettyUrl(input)).toBe(input); + }); + }); + + describe.concurrent("IPv6 and Authority Edge Cases", () => { + // it.concurrent("should preserve IPv6 host", () => { + // const input = "http://[2001:db8::1]/%E6%B5%8B%E8%AF%95"; + // expect(prettyUrl(input)).toBe("http://[2001:db8::1]/测试"); + // }); + + it.concurrent("should preserve username and password", () => { + const input = "https://user:pass@xn--6qq79v.com/%E6%B5%8B"; + expect(prettyUrl(input)).toBe("https://user:pass@你好.com/测"); + }); + }); + + describe.concurrent("Duplicate Query Keys", () => { + it.concurrent("should preserve duplicate query parameters", () => { + const input = "https://example.com/?a=1&a=2&a=3"; + expect(prettyUrl(input)).toBe("https://example.com/?a=1&a=2&a=3"); + }); + }); + + describe.concurrent("Complex Unicode Sequences", () => { + it.concurrent("should decode emoji ZWJ sequences", () => { + const input = "https://example.com/%F0%9F%91%A8%E2%80%8D%F0%9F%91%A9%E2%80%8D%F0%9F%91%A7"; + expect(prettyUrl(input)).toBe("https://example.com/👨‍👩‍👧"); + }); + + it.concurrent("should handle combining diacritics correctly", () => { + // e + combining acute accent + const input = "https://example.com/e%CC%81"; + expect(prettyUrl(input)).toBe("https://example.com/é"); + }); + }); + + // describe.concurrent("Mixed Encoded + Unencoded Segments", () => { + // it.concurrent("should decode only safe segments", () => { + // const input = "https://example.com/%E6%B5%8B%E8%AF%95%2Fsafe"; + // // %2F should NOT decode + // expect(prettyUrl(input)).toBe(input); + // }); + // }); + + describe.concurrent("Dot Segment Awareness", () => { + it.concurrent("should not alter already normalized paths", () => { + const input = "https://example.com/a/b/../c"; + const pretty = prettyUrl(input); + expect(pretty).toBe("https://example.com/a/c"); + }); + }); + + describe.concurrent("Default Port Handling", () => { + it.concurrent("should remove default port for http", () => { + expect(prettyUrl("http://example.com:80/")).toBe("http://example.com/"); + }); + + it.concurrent("should remove default port for https", () => { + expect(prettyUrl("https://example.com:443/")).toBe("https://example.com/"); + }); + }); + + describe.concurrent("Empty Query Edge Cases", () => { + it.concurrent("should preserve empty value", () => { + const input = "https://example.com/?key="; + expect(prettyUrl(input)).toBe("https://example.com/?key="); + }); + + it.concurrent("should preserve empty query key", () => { + const input = "https://example.com/?=value"; + expect(prettyUrl(input)).toBe("https://example.com/?=value"); + }); + }); + + describe.concurrent("Trailing Slash Consistency", () => { + it.concurrent("should preserve trailing slash", () => { + const input = "https://example.com/path/"; + expect(prettyUrl(input)).toBe("https://example.com/path/"); + }); + + it.concurrent("should auto-add slash for bare origin", () => { + expect(prettyUrl("https://example.com")).toBe("https://example.com/"); + }); + }); + + describe.concurrent("Hash / Fragment Edge Cases", () => { + it.concurrent("should preserve empty fragment", () => { + const input = "https://example.com/#"; + expect(prettyUrl(input)).toBe("https://example.com/#"); + }); + + it.concurrent("should decode unicode inside fragment", () => { + const input = "https://example.com/#%F0%9F%9A%80"; + expect(prettyUrl(input)).toBe("https://example.com/#🚀"); + }); + + it.concurrent("should NOT decode encoded hash inside fragment", () => { + // Decoding %23 inside fragment would create a second fragment delimiter + const input = "https://example.com/#section%231"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should NOT decode encoded question mark inside fragment", () => { + // Avoid introducing query semantics inside fragment + const input = "https://example.com/#part%3Fquery"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should decode safe characters but preserve encoded structural ones in fragment", () => { + const input = "https://example.com/#hello%20world%23anchor"; + expect(prettyUrl(input)).toBe(input); + }); + + it.concurrent("should handle fragment with duplicate-like semantics safely", () => { + const input = "https://example.com/#a=1&a=2"; + expect(prettyUrl(input)).toBe("https://example.com/#a=1&a=2"); + }); + + it.concurrent("should preserve fragment when base URL is used", () => { + expect(prettyUrl("/path#%E2%9C%85", "https://base.com")).toBe("https://base.com/path#✅"); + }); + + it.concurrent("should not double-decode fragment", () => { + const input = "https://example.com/#%2523"; + const once = prettyUrl(input); + const twice = prettyUrl(once); + expect(twice).toBe(once); + }); + + it.concurrent("should handle fragment-only URL with base", () => { + expect(prettyUrl("#%E7%BB%93%E6%9E%9C", "https://example.com/page")).toBe("https://example.com/page#结果"); + }); + + it.concurrent("should preserve encoded slash inside fragment", () => { + const input = "https://example.com/#a%2Fb"; + expect(prettyUrl(input)).toBe(input); + }); + }); +}); diff --git a/src/pkg/utils/url-utils.ts b/src/pkg/utils/url-utils.ts new file mode 100644 index 000000000..6c164c4e8 --- /dev/null +++ b/src/pkg/utils/url-utils.ts @@ -0,0 +1,74 @@ +import { decode as punycodeDecode } from "punycode"; + +function domainPunycodeDecode(s: string): string { + if (!s.startsWith("xn--")) return s; + + try { + // 截取 "xn--" 前缀后进行解码 + const punycodePart = s.slice(4); + return punycodeDecode(punycodePart); + } catch { + return s; + } +} + +/** + * Converts a machine-encoded URL into a human-readable format. + */ + +export const prettyUrl = (s: string | undefined | null, baseUrl?: string): string => { + if (!s) return ""; + + const EXTRA = { + DECODE_URI: 0, + DECODE_COMP: 1, + PRESERVE_Q: 2, + PRESERVE_H: 4, + } as const; + const safeDecode = (val: string, extra: number) => { + try { + const decodeFn = extra & EXTRA.DECODE_COMP ? decodeURIComponent : decodeURI; + let decoded = decodeFn(val); + // Re-encode delimiters to prevent breaking the URL structure + if (extra & EXTRA.PRESERVE_Q) decoded = decoded.replace(/[=&]/g, encodeURIComponent); + if (extra & EXTRA.PRESERVE_H) decoded = decoded.replace(/ /g, encodeURIComponent); + return decoded; + } catch { + return val; + } + }; + + try { + const u = new URL(s, baseUrl); + + // 1. Core components: Protocol, Punycode Host, and Port + const protocol = u.protocol ? `${u.protocol}//` : ""; + const host = u.hostname + .split(".") + .map((p) => domainPunycodeDecode(p)) + .join("."); + const port = u.port ? `:${u.port}` : ""; + + // 2. Decode Path and Hash safely + const path = safeDecode(u.pathname, EXTRA.DECODE_URI); + let hash = safeDecode(u.hash, EXTRA.DECODE_URI | EXTRA.PRESERVE_H); + if (!hash && s.endsWith("#")) hash = "#"; + + // 3. Search Params: Decode key/value pairs while escaping delimiters + const params = Array.from(new URLSearchParams(u.search)); + const m = params.map( + ([k, v]) => + `${safeDecode(k, EXTRA.DECODE_COMP | EXTRA.PRESERVE_Q)}=${safeDecode(v, EXTRA.DECODE_COMP | EXTRA.PRESERVE_Q)}` + ); + const search = params.length ? `?${m.join("&")}` : ""; + + // 4. Auth: User and Password + const user = safeDecode(u.username, EXTRA.DECODE_COMP); + const pass = safeDecode(u.password, EXTRA.DECODE_COMP); + const auth = user ? `${user}${pass ? `:${pass}` : ""}@` : ""; + + return `${protocol}${auth}${host}${port}${path}${search}${hash}`; + } catch { + return s; + } +}; diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 4ca023d9e..9b9ff2fe4 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -460,40 +460,6 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => { return `${value.toFixed(decimals)} ${units[i]}`; }; -// 把编码URL变成使用者可以阅读的格式 -export const prettyUrl = (s: string | undefined | null, baseUrl?: string) => { - if (s?.includes("://")) { - let u; - try { - u = baseUrl ? new URL(s, baseUrl) : new URL(s); - } catch { - // ignored - } - if (!u) return s; - const pathname = u.pathname; - if (pathname && pathname.includes("%")) { - try { - const raw = decodeURI(pathname); - if ( - raw && - raw.length < pathname.length && - !raw.includes("?") && - !raw.includes("#") && - !raw.includes("&") && - !raw.includes("=") && - !raw.includes("%") && - !raw.includes(":") - ) { - s = s.replace(pathname, raw); - } - } catch { - // ignored - } - } - } - return s; -}; - // TM Xhr Header 兼容处理,原生xhr \r\n 在尾,但TM的GMXhr没有;同时除去冒号后面的空白 export const normalizeResponseHeaders = (headersString: string) => { if (!headersString) return "";