From 9700ab7087761d4955f2aeab33a47081da47c36b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 27 Jul 2026 10:46:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E5=9B=BE=E6=A0=87=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=96=B0=E5=A2=9E=E3=80=8C=E7=A6=81=E7=94=A8=E3=80=8D?= =?UTF-8?q?=E6=A1=A3,=E5=BD=BB=E5=BA=95=E5=85=B3=E9=97=AD=20favicon=20?= =?UTF-8?q?=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开脚本猫时会按脚本的 @match 域名逐个请求 favicon,@match 多的脚本会 产生大量外部请求,并触发安全软件拦截。此前只能换图标服务,无法真正关掉。 在「设置 - 界面 - 图标服务」下拉中增加「禁用」一档(不新增设置行):选中后 loadScriptFavicons 直接返回,不读写 favicon 缓存也不发起任何请求;订阅页 @connect 域名图标同样遵守该开关,且在配置读取完成前先渲染占位,避免 挂载即发出请求。 Close #1255 --- src/locales/de-DE/settings.json | 1 + src/locales/en-US/settings.json | 1 + src/locales/ja-JP/settings.json | 1 + src/locales/ko-KR/settings.json | 1 + src/locales/pt-BR/settings.json | 1 + src/locales/ru-RU/settings.json | 1 + src/locales/tr-TR/settings.json | 1 + src/locales/vi-VN/settings.json | 1 + src/locales/zh-CN/settings.json | 1 + src/locales/zh-TW/settings.json | 1 + .../sections/InterfaceSection.test.tsx | 7 +++ .../Setting/sections/InterfaceSection.tsx | 1 + .../routes/SubscribeList/components.test.tsx | 51 +++++++++++++++++++ .../routes/SubscribeList/components.tsx | 10 ++-- src/pages/store/favicons.test.ts | 31 +++++++++++ src/pages/store/favicons.ts | 6 +++ src/pkg/config/config.ts | 3 +- 17 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/pages/options/routes/SubscribeList/components.test.tsx diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 3b6e562d8..d9f7b3ddb 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Favicon-Dienst", "favicon_service_desc": "Dienst zum Abrufen von Website-Symbolen auswählen", + "favicon_service_none": "Deaktiviert", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index 509d573ba..acff52b63 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Favicon Service", "favicon_service_desc": "Choose the service for fetching website icons", + "favicon_service_none": "Disabled", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index b18950e97..f212a11a1 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Favicon サービス", "favicon_service_desc": "ウェブサイトアイコンの取得サービスを選択", + "favicon_service_none": "無効", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/ko-KR/settings.json b/src/locales/ko-KR/settings.json index cef9fc167..ab94b9b1c 100644 --- a/src/locales/ko-KR/settings.json +++ b/src/locales/ko-KR/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "파비콘 서비스", "favicon_service_desc": "웹사이트 아이콘을 가져올 서비스를 선택하세요", + "favicon_service_none": "비활성화", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index 9f4004db9..104625aff 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Serviço de favicon", "favicon_service_desc": "Escolha o serviço para buscar os ícones dos sites", + "favicon_service_none": "Desativado", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 156dbb7d4..1cbd5eb2d 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Сервис Favicon", "favicon_service_desc": "Выберите сервис для получения значков сайтов", + "favicon_service_none": "Выключено", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index 10fe021da..73a396c8e 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Favicon Hizmeti", "favicon_service_desc": "Web sitesi simgelerini almak için hizmeti seçin", + "favicon_service_none": "Devre Dışı", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 44c521d36..bb4f0d8da 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "Dịch vụ Favicon", "favicon_service_desc": "Chọn dịch vụ để lấy biểu tượng trang web", + "favicon_service_none": "Đã tắt", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index af451511b..660aa1650 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "图标服务", "favicon_service_desc": "选择获取网站图标的服务", + "favicon_service_none": "禁用", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 361f57576..edbd54d54 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -124,6 +124,7 @@ }, "favicon_service": "圖示服務", "favicon_service_desc": "選擇取得網站圖示的服務", + "favicon_service_none": "停用", "favicon_service_scriptcat": "ScriptCat", "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", diff --git a/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx b/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx index dcfe1a62d..5c20a558e 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx @@ -34,6 +34,13 @@ describe("界面分区-图标服务", () => { await screen.findByText("扩展图标徽标"); expect(screen.getAllByText("扩展图标徽标")).toHaveLength(1); }); + + // 图标服务可整体关闭:选中 none 时下拉框需回显「禁用」,否则用户无从选择这一档 + it("图标服务为 none 时下拉框回显禁用", async () => { + get.mockImplementation((key: string) => Promise.resolve(key === "favicon_service" ? "none" : "")); + render( () => {}} />); + expect(await screen.findByText("禁用")).toBeInTheDocument(); + }); }); describe("界面分区-popup 布局", () => { diff --git a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx index 544612dda..0288ff09d 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx @@ -77,6 +77,7 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: + {t("settings:favicon_service_none")} {t("settings:favicon_service_scriptcat")} {t("settings:favicon_service_google")} {t("settings:favicon_service_duckduckgo")} diff --git a/src/pages/options/routes/SubscribeList/components.test.tsx b/src/pages/options/routes/SubscribeList/components.test.tsx new file mode 100644 index 000000000..0cbf390a1 --- /dev/null +++ b/src/pages/options/routes/SubscribeList/components.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; + +const { get } = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@App/pages/store/global", async () => { + const { createGlobalStoreMock } = await import("@Tests/mocks/pageStores.ts"); + return createGlobalStoreMock({ systemConfig: { get } }); +}); + +vi.mock("@App/pages/store/features/subscribe", () => ({ + requestCheckSubscribeUpdate: vi.fn(() => Promise.resolve(false)), +})); + +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { TooltipProvider } from "@App/pages/components/ui/tooltip"; +import { PermissionFavicons } from "./components"; + +beforeAll(() => { + initTestLanguage("zh-CN"); +}); + +afterEach(() => { + cleanup(); + get.mockReset(); +}); + +const renderFavicons = () => + render( + + + + ); + +describe("订阅 @connect 域名图标", () => { + it("图标服务可用时以站点 favicon 呈现", async () => { + get.mockResolvedValue("scriptcat"); + renderFavicons(); + + const img = await screen.findByAltText("example.com"); + expect(img).toHaveAttribute("src", "https://example.com/favicon.ico"); + }); + + it("图标服务禁用时不向站点请求 favicon", async () => { + get.mockResolvedValue("none"); + const { container } = renderFavicons(); + + // 配置读取是异步的:等到读取完成后仍不能出现 ,否则请求早已发出 + await waitFor(() => expect(get).toHaveBeenCalledWith("favicon_service")); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/src/pages/options/routes/SubscribeList/components.tsx b/src/pages/options/routes/SubscribeList/components.tsx index 061864825..f5cd66570 100644 --- a/src/pages/options/routes/SubscribeList/components.tsx +++ b/src/pages/options/routes/SubscribeList/components.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import { cn } from "@App/pkg/utils/cn"; import { notify } from "@App/pages/components/ui/toast"; import { NameAvatar } from "@App/pages/components/NameAvatar"; +import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; import { Globe, RefreshCw, Rss, Trash2, CircleArrowUp, Check, Loader2, Link } from "lucide-react"; // ========== SubscribeIcon ========== @@ -45,12 +46,12 @@ SubscribeEnableSwitch.displayName = "SubscribeEnableSwitch"; // ========== PermissionFavicons ========== // @connect 域名以站点 favicon 呈现,加载失败回退到地球图标 -function DomainFavicon({ domain }: { domain: string }) { +function DomainFavicon({ domain, showIcon }: { domain: string; showIcon: boolean }) { const [imgError, setImgError] = useState(false); return ( - {imgError ? ( + {!showIcon || imgError ? ( ) : ( { + const [faviconService] = useSystemConfig("favicon_service"); + // 配置读取完成前一律先显示占位: 一旦挂载请求就已发出,关闭图标获取后再撤下已经晚了 + const showIcon = !!faviconService && faviconService !== "none"; if (!connect || connect.length === 0) return {"-"}; const visible = connect.slice(0, maxShow); const extra = connect.length - maxShow; return (
{visible.map((domain) => ( - + ))} {extra > 0 && {`+${extra}`}}
diff --git a/src/pages/store/favicons.test.ts b/src/pages/store/favicons.test.ts index 75c04442b..ce157a52f 100644 --- a/src/pages/store/favicons.test.ts +++ b/src/pages/store/favicons.test.ts @@ -4,7 +4,9 @@ import { parseFaviconsNew, fetchIconByService, fetchIconByDomain, + loadScriptFavicons, } from "@App/pages/store/favicons"; +import type { Script } from "@App/app/repo/scripts"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; describe("extractFaviconsDomain", () => { @@ -172,9 +174,38 @@ describe("fetchIconByService", () => { ]); }); + it("none 服务不应返回任何图标地址", async () => { + const result = await fetchIconByService("example.com", "none"); + expect(result).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + // local 服务的具体行为已在 fetchIconByDomain 测试中充分覆盖 }); +describe("loadScriptFavicons", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("图标服务设为 none 时不产出图标,也不发起任何请求", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const scripts = [ + { uuid: "uuid-1", metadata: { match: ["https://example.com/*"] } }, + { uuid: "uuid-2", metadata: { match: ["https://another.com/*"] } }, + ] as unknown as Script[]; + + const chunks = []; + for await (const chunk of loadScriptFavicons(scripts, "none")) { + chunks.push(chunk); + } + + expect(chunks).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + describe("fetchIconByDomain", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 95c599695..229a60290 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -187,6 +187,10 @@ export async function fetchIconByDomain(domain: string): Promise { */ export async function fetchIconByService(domain: string, service: FaviconService): Promise { switch (service) { + case "none": + // 用户关闭了图标获取,不产生任何外部请求 + return []; + case "scriptcat": /** * ScriptCat 图标服务 @@ -382,6 +386,8 @@ type TFaviconStack = { chunkResults: FavIconResult[]; pendingCount: number }; // 处理favicon加载,以批次方式处理 export const loadScriptFavicons = async function* (scripts: Script[], service: FaviconService = "scriptcat") { + // 关闭图标获取时直接结束:不读写 favicon 缓存,也不发起任何请求 + if (service === "none") return; const stack: TFaviconStack[] = []; const asyncWaiter: { promise?: any; resolve?: any } = {}; const createPromise = () => { diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 026ee2290..fb50f668e 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -37,7 +37,8 @@ export const DEFAULT_CLOUD_SYNC_STATE: CloudSyncState = { counts: { total: 0, overwrite: 0, conflict: 0, failed: 0 }, }; -export type FaviconService = "scriptcat" | "google" | "duckduckgo" | "icon-horse" | "local"; +// "none" 表示彻底关闭网站图标获取:不向任何图标服务或目标站点发起请求 +export type FaviconService = "none" | "scriptcat" | "google" | "duckduckgo" | "icon-horse" | "local"; export type CATFileStorage = { filesystem: FileSystemType;