From c236857208adedf7c1fade5d1d9e8b50383461c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 16:21:43 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=89=8B=E5=8A=A8=E6=A3=80=E6=9F=A5=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=A7=8B=E7=BB=88=E6=98=BE=E7=A4=BA=E5=B7=B2=E6=98=AF=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=20(#1773)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/subscribe.test.ts | 38 ++++++++++++++++++- src/app/service/service_worker/subscribe.ts | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/subscribe.test.ts b/src/app/service/service_worker/subscribe.test.ts index c85301fc5..978f4f926 100644 --- a/src/app/service/service_worker/subscribe.test.ts +++ b/src/app/service/service_worker/subscribe.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { initTestEnv } from "@Tests/utils"; import { SubscribeService } from "./subscribe"; import type { ScriptService } from "./script"; @@ -9,6 +9,7 @@ import type { Subscribe } from "@App/app/repo/subscribe"; import { MessageQueue } from "@Packages/message/message_queue"; import { MockMessage } from "@Packages/message/mock_message"; import { Server } from "@Packages/message/server"; +import { SubscribeClient } from "./client"; import EventEmitter from "eventemitter3"; initTestEnv(); @@ -85,3 +86,38 @@ describe("SubscribeService —— 删除脚本的来源标记", () => { expect(scriptService.deleteScript).toHaveBeenCalledWith("sub-script-1", "subscribe"); }); }); + +// options 页经 SubscribeClient 以 { url } 发送检查更新;SW 端若按字符串接收, +// 会以 "[object Object]" 查订阅而查不到,手动检查永远显示「已是最新」且不发请求。 +describe("SubscribeService —— 手动检查更新", () => { + beforeEach(async () => { + await chrome.storage.local.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("经 SubscribeClient 检查更新时应拉取订阅并发现新版本", async () => { + await new SubscribeDAO().save(makeSubscribe({ scripts: {}, metadata: { usersubscribe: [], version: ["0.3.3"] } })); + const fetchMock = vi.fn( + async () => + new Response( + "// ==UserSubscribe==\n// @name 测试订阅\n// @namespace ns\n// @version 0.3.4\n// ==/UserSubscribe==\n", + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + // chrome.alarms 在 chrome-extension-mock 中没有实现,init() 会调用 clear + vi.stubGlobal("chrome", { ...chrome, alarms: { clear: vi.fn() } }); + const mockMessage = new MockMessage(new EventEmitter()); + const server = new Server("serviceWorker", mockMessage); + const service = new SubscribeService(server.group("subscribe"), new MessageQueue(), {} as ScriptService); + service.init(); + + const res = await new SubscribeClient(mockMessage).checkUpdate(SUB_URL); + + expect(fetchMock).toHaveBeenCalledWith(SUB_URL, expect.anything()); + expect(res).toBe(true); + }); +}); diff --git a/src/app/service/service_worker/subscribe.ts b/src/app/service/service_worker/subscribe.ts index 12adf9ecf..bfafc691a 100644 --- a/src/app/service/service_worker/subscribe.ts +++ b/src/app/service/service_worker/subscribe.ts @@ -321,7 +321,7 @@ export class SubscribeService { } } - requestCheckUpdate(url: string) { + requestCheckUpdate({ url }: { url: string }) { return this.checkUpdate(url, "user"); } From 2278761a3335281bf94eda9f80402bddbed95f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 28 Sep 2026 16:57:51 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20=E8=AE=A2=E9=98=85=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=90=8E=E5=90=8C=E6=AD=A5=E5=88=B7=E6=96=B0=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E5=88=97=E8=A1=A8=EF=BC=8C=E9=9D=99=E9=BB=98=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=8A=A5=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/client.ts | 3 +- .../service/service_worker/subscribe.test.ts | 87 ++++++++++++++---- src/app/service/service_worker/subscribe.ts | 16 +++- .../routes/SubscribeList/components.test.tsx | 42 ++++++++- .../routes/SubscribeList/components.tsx | 13 ++- .../routes/SubscribeList/hooks.test.ts | 88 +++++++++++++++++++ .../options/routes/SubscribeList/hooks.ts | 22 ++++- 7 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 src/pages/options/routes/SubscribeList/hooks.test.ts diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index b58a27912..d872a01f8 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -26,6 +26,7 @@ import { type ResourceBackup } from "@App/pkg/backup/struct"; import { type ConfigBundle } from "@App/pkg/backup/config_bundle"; import { type VSCodeConnectParam } from "../offscreen/vscode-connect"; import { type ScriptInfo } from "@App/pkg/utils/scriptInstall"; +import { type SubscribeCheckUpdateResult } from "./subscribe"; import type { AgentModelConfig, AgentTaskApiRequest, @@ -483,7 +484,7 @@ export class SubscribeClient extends Client { } checkUpdate(url: string) { - return this.do("checkUpdate", { url }); + return this.do("checkUpdate", { url }); } enable(url: string, enable: boolean) { diff --git a/src/app/service/service_worker/subscribe.test.ts b/src/app/service/service_worker/subscribe.test.ts index 978f4f926..29a437d80 100644 --- a/src/app/service/service_worker/subscribe.test.ts +++ b/src/app/service/service_worker/subscribe.test.ts @@ -6,10 +6,12 @@ import { ScriptDAO, SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_ import type { Script } from "@App/app/repo/scripts"; import { SubscribeDAO, SubscribeStatusType } from "@App/app/repo/subscribe"; import type { Subscribe } from "@App/app/repo/subscribe"; +import type { TInstallSubscribe } from "../queue"; import { MessageQueue } from "@Packages/message/message_queue"; import { MockMessage } from "@Packages/message/mock_message"; import { Server } from "@Packages/message/server"; import { SubscribeClient } from "./client"; +import { createMockOPFS } from "@App/app/repo/test-helpers"; import EventEmitter from "eventemitter3"; initTestEnv(); @@ -55,7 +57,7 @@ const buildService = () => { installByUrl: vi.fn(async () => makeScript()), } as unknown as ScriptService; const service = new SubscribeService(group, mq, scriptService); - return { service, scriptService }; + return { service, scriptService, mq }; }; // 回收站按 deleteBy 提供「订阅」来源筛选;订阅链路删除脚本时若不标记来源, @@ -87,37 +89,92 @@ describe("SubscribeService —— 删除脚本的来源标记", () => { }); }); +// 订阅列表页只在挂载时拉取一次数据,列表要跟上后台变化,只能靠脚本同步完成后的广播 +describe("SubscribeService —— 订阅列表同步广播", () => { + beforeEach(async () => { + await chrome.storage.local.clear(); + }); + + it("订阅脚本同步完成后应广播含最新脚本关联的订阅", async () => { + await new SubscribeDAO().save(makeSubscribe({ scripts: {} })); + const { service, mq } = buildService(); + const published = new Promise((resolve) => mq.subscribe("upsertSubscribe", resolve)); + + await service.upsertScript(SUB_URL); + + const { subscribe } = await published; + expect(subscribe.url).toBe(SUB_URL); + expect(subscribe.scripts[SCRIPT_URL]).toEqual({ url: SCRIPT_URL, uuid: "sub-script-1" }); + }); +}); + +const subscribeCode = (version: string, connect: string[] = []) => + [ + "// ==UserSubscribe==", + "// @name 测试订阅", + "// @namespace ns", + `// @version ${version}`, + ...connect.map((c) => `// @connect ${c}`), + "// ==/UserSubscribe==", + "", + ].join("\n"); + // options 页经 SubscribeClient 以 { url } 发送检查更新;SW 端若按字符串接收, // 会以 "[object Object]" 查订阅而查不到,手动检查永远显示「已是最新」且不发请求。 describe("SubscribeService —— 手动检查更新", () => { beforeEach(async () => { await chrome.storage.local.clear(); + // 需用户确认时更新代码暂存于 OPFS,再打开安装页 + createMockOPFS(); }); afterEach(() => { - vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); - it("经 SubscribeClient 检查更新时应拉取订阅并发现新版本", async () => { + const setup = async (remoteCode: string) => { await new SubscribeDAO().save(makeSubscribe({ scripts: {}, metadata: { usersubscribe: [], version: ["0.3.3"] } })); - const fetchMock = vi.fn( - async () => - new Response( - "// ==UserSubscribe==\n// @name 测试订阅\n// @namespace ns\n// @version 0.3.4\n// ==/UserSubscribe==\n", - { status: 200 } - ) - ); - vi.stubGlobal("fetch", fetchMock); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response(remoteCode, { status: 200 })); + const tabsCreate = vi.spyOn(chrome.tabs, "create"); // chrome.alarms 在 chrome-extension-mock 中没有实现,init() 会调用 clear - vi.stubGlobal("chrome", { ...chrome, alarms: { clear: vi.fn() } }); + Object.assign(chrome, { alarms: { clear: vi.fn() } }); const mockMessage = new MockMessage(new EventEmitter()); const server = new Server("serviceWorker", mockMessage); - const service = new SubscribeService(server.group("subscribe"), new MessageQueue(), {} as ScriptService); + const mq = new MessageQueue(); + const service = new SubscribeService(server.group("subscribe"), mq, {} as ScriptService); service.init(); + return { client: new SubscribeClient(mockMessage), fetchMock, tabsCreate, mq }; + }; + + it("远端版本更高且无需确认时应静默更新并返回 updated", async () => { + const { client, fetchMock, tabsCreate, mq } = await setup(subscribeCode("0.3.4")); + const synced = new Promise((resolve) => mq.subscribe("upsertSubscribe", resolve)); - const res = await new SubscribeClient(mockMessage).checkUpdate(SUB_URL); + const res = await client.checkUpdate(SUB_URL); expect(fetchMock).toHaveBeenCalledWith(SUB_URL, expect.anything()); - expect(res).toBe(true); + expect(res).toBe("updated"); + expect((await synced).subscribe.metadata.version).toEqual(["0.3.4"]); + expect(tabsCreate).not.toHaveBeenCalled(); + }); + + it("远端新增 @connect 域时应打开安装页并返回 confirm", async () => { + const { client, tabsCreate } = await setup(subscribeCode("0.3.4", ["new.example.com"])); + + const res = await client.checkUpdate(SUB_URL); + + expect(res).toBe("confirm"); + expect(tabsCreate).toHaveBeenCalledWith({ url: expect.stringContaining("/src/install.html?uuid=") }); + }); + + it("远端版本未升高时返回 false", async () => { + const { client, tabsCreate } = await setup(subscribeCode("0.3.3")); + + const res = await client.checkUpdate(SUB_URL); + + expect(res).toBe(false); + expect(tabsCreate).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/service_worker/subscribe.ts b/src/app/service/service_worker/subscribe.ts index bfafc691a..c875cbfcd 100644 --- a/src/app/service/service_worker/subscribe.ts +++ b/src/app/service/service_worker/subscribe.ts @@ -16,6 +16,9 @@ import i18n, { i18nName } from "@App/locales/locales"; import { InfoNotification } from "./utils"; import { TempStorageDAO, TempStorageItemType } from "@App/app/repo/tempStorage"; +// updated:已静默更新;confirm:@connect 新增了域,已打开安装页等待用户确认;false:无更新或检查失败 +export type SubscribeCheckUpdateResult = "updated" | "confirm" | false; + export class SubscribeService { logger: Logger; subscribeDAO = new SubscribeDAO(); @@ -188,7 +191,11 @@ export class SubscribeService { await Promise.allSettled(promises); // 把 subscribe.scripts 的新资讯储存到 subscribeDAO - await this.subscribeDAO.update(subscribe.url, subscribe); + const saved = await this.subscribeDAO.update(subscribe.url, subscribe); + // 脚本关联到此才是最终结果,订阅列表页据此刷新;installSubscribe 早于脚本同步,不能用于刷新 + if (saved) { + this.mq.publish("upsertSubscribe", { subscribe: saved }); + } InfoNotification( i18n.t("settings:notification.subscribe_update", { subscribeName: subscribe.name }), @@ -247,7 +254,7 @@ export class SubscribeService { * @param source 系统自动检查: "system"; subscribeClient.checkUpdate(subscribe.url) 的时候: "user" * @returns */ - async checkUpdate(url: string, source: InstallSource) { + async checkUpdate(url: string, source: InstallSource): Promise { const subscribe = await this.subscribeDAO.get(url); if (!subscribe) { return false; @@ -266,7 +273,7 @@ export class SubscribeService { try { // 进行更新 if (true === (await this.trySilenceUpdate(code, url))) { - // slience update + return "updated"; } else { const si = await createTempCodeEntry(false, uuid, code, url, source, metadata, {}); await new TempStorageDAO().save({ @@ -279,12 +286,13 @@ export class SubscribeService { url: `/src/install.html?uuid=${uuid}`, }); } - return true; + return "confirm"; } catch (e) { logger.error("check update failed", { ...Logger.E(e), subscribe_url: url }); return false; } } + return false; } // 订阅始终尝试静默更新,不受「非重要变更静默更新脚本」开关控制 diff --git a/src/pages/options/routes/SubscribeList/components.test.tsx b/src/pages/options/routes/SubscribeList/components.test.tsx index 0cbf390a1..fd400c8d6 100644 --- a/src/pages/options/routes/SubscribeList/components.test.tsx +++ b/src/pages/options/routes/SubscribeList/components.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { act, render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; const { get } = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@App/pages/store/global", async () => { @@ -13,7 +13,9 @@ vi.mock("@App/pages/store/features/subscribe", () => ({ import { initTestLanguage } from "@Tests/initTestLanguage"; import { TooltipProvider } from "@App/pages/components/ui/tooltip"; -import { PermissionFavicons } from "./components"; +import { t } from "@App/locales/locales"; +import { requestCheckSubscribeUpdate } from "@App/pages/store/features/subscribe"; +import { PermissionFavicons, SubscribeUpdateTimeCell } from "./components"; beforeAll(() => { initTestLanguage("zh-CN"); @@ -49,3 +51,39 @@ describe("订阅 @connect 域名图标", () => { expect(container.querySelector("img")).toBeNull(); }); }); + +describe("订阅更新时间格的检查更新状态", () => { + const URL = "https://example.com/list.user.sub.js"; + const renderCell = (updatetime: number) => ( + + + + ); + const clickCheck = () => + act(async () => { + fireEvent.click(screen.getByRole("button", { name: t("check_update") })); + }); + + it("已静默更新时不提示「存在新版本」", async () => { + vi.mocked(requestCheckSubscribeUpdate).mockResolvedValueOnce("updated"); + render(renderCell(1000)); + + await clickCheck(); + + expect(requestCheckSubscribeUpdate).toHaveBeenCalledWith(URL); + expect(screen.queryByText(t("script:new_version_available"))).toBeNull(); + expect(screen.queryByText(t("script:latest_version"))).toBeNull(); + }); + + it("打开安装页待确认时提示「存在新版本」,订阅更新后恢复为更新时间", async () => { + vi.mocked(requestCheckSubscribeUpdate).mockResolvedValueOnce("confirm"); + const { rerender } = render(renderCell(1000)); + + await clickCheck(); + expect(screen.getByText(t("script:new_version_available"))).toBeInTheDocument(); + + rerender(renderCell(2000)); + + expect(screen.queryByText(t("script:new_version_available"))).toBeNull(); + }); +}); diff --git a/src/pages/options/routes/SubscribeList/components.tsx b/src/pages/options/routes/SubscribeList/components.tsx index a6f444825..cdc914d4d 100644 --- a/src/pages/options/routes/SubscribeList/components.tsx +++ b/src/pages/options/routes/SubscribeList/components.tsx @@ -104,19 +104,26 @@ export const SubscribeSourceTag = React.memo(({ url }: { url: string }) => { SubscribeSourceTag.displayName = "SubscribeSourceTag"; // ========== UpdateTimeCell ========== -// 「最后更新」就近放置检查更新入口:idle → checking → latest(2s 恢复)/ has-update +// 「最后更新」就近放置检查更新入口:idle → checking → latest(2s 恢复)/ has-update / idle(已静默更新) type CheckUpdateState = "idle" | "checking" | "latest" | "has-update"; export const SubscribeUpdateTimeCell = React.memo(({ url, updatetime }: { url: string; updatetime?: number }) => { const { t } = useTranslation(); const [state, setState] = useState("idle"); + // 用户在安装页确认更新后 updatetime 随广播变化,「存在新版本」已失效 + const [prevUpdatetime, setPrevUpdatetime] = useState(updatetime); + if (updatetime !== prevUpdatetime) { + setPrevUpdatetime(updatetime); + if (state === "has-update") setState("idle"); + } + const handleCheck = useCallback(() => { if (state === "checking") return; setState("checking"); requestCheckSubscribeUpdate(url) - // res 为 true 时表示发现新版本并已打开更新页;false/undefined 表示已是最新 - .then((res) => setState(res ? "has-update" : "latest")) + // updated:已静默更新,新的更新时间随广播到达;confirm:已打开安装页待确认 + .then((res) => setState(res === "updated" ? "idle" : res === "confirm" ? "has-update" : "latest")) .catch((e) => { setState("idle"); notify.error(`${t("script:update_check_failed")}: ${e}`); diff --git a/src/pages/options/routes/SubscribeList/hooks.test.ts b/src/pages/options/routes/SubscribeList/hooks.test.ts new file mode 100644 index 000000000..652431d18 --- /dev/null +++ b/src/pages/options/routes/SubscribeList/hooks.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { Subscribe } from "@App/app/repo/subscribe"; +import { SubscribeStatusType } from "@App/app/repo/subscribe"; +import type { TInstallSubscribe } from "@App/app/service/queue"; + +const { messageHandlers, fetchSubscribeList } = vi.hoisted(() => ({ + messageHandlers: new Map void>(), + fetchSubscribeList: vi.fn(), +})); + +vi.mock("@App/pages/store/global", async () => { + const { createGlobalStoreMock } = await import("@Tests/mocks/pageStores.ts"); + return { + ...createGlobalStoreMock(), + subscribeMessage: vi.fn((topic: string, handler: (data: object) => void) => { + messageHandlers.set(topic, handler); + return () => messageHandlers.delete(topic); + }), + }; +}); + +vi.mock("@App/pages/store/features/subscribe", () => ({ fetchSubscribeList })); + +import { useSubscribeDataManagement } from "./hooks"; + +const mk = (url: string, overrides: Partial = {}): Subscribe => ({ + url, + name: url, + code: "", + author: "", + scripts: {}, + metadata: { usersubscribe: [], version: ["0.1.0"] }, + status: SubscribeStatusType.enable, + createtime: 1, + updatetime: 1, + checktime: 1, + ...overrides, +}); + +const A = "https://example.com/a.user.sub.js"; +const B = "https://example.com/b.user.sub.js"; + +const publish = (subscribe: Subscribe) => + act(() => messageHandlers.get("upsertSubscribe")?.({ subscribe } satisfies TInstallSubscribe)); + +beforeEach(() => { + messageHandlers.clear(); + fetchSubscribeList.mockResolvedValue([mk(A)]); +}); + +// 列表只在挂载时拉取一次;后台静默更新或在安装页装好订阅后,列表要跟随广播刷新 +describe("订阅列表数据随后台广播同步", () => { + it("已有订阅更新后就地替换该行", async () => { + const { result } = renderHook(() => useSubscribeDataManagement()); + await waitFor(() => expect(result.current.loadingList).toBe(false)); + + publish( + mk(A, { + metadata: { usersubscribe: [], version: ["0.2.0"] }, + scripts: { "https://example.com/x.user.js": { url: "https://example.com/x.user.js", uuid: "x" } }, + updatetime: 2, + }) + ); + + expect(result.current.subscribeList).toHaveLength(1); + expect(result.current.subscribeList[0].metadata.version).toEqual(["0.2.0"]); + expect(Object.keys(result.current.subscribeList[0].scripts)).toEqual(["https://example.com/x.user.js"]); + }); + + it("新安装的订阅按创建时间追加", async () => { + const { result } = renderHook(() => useSubscribeDataManagement()); + await waitFor(() => expect(result.current.loadingList).toBe(false)); + + publish(mk(B, { createtime: 2 })); + + expect(result.current.subscribeList.map((s) => s.url)).toEqual([A, B]); + }); + + it("卸载后取消订阅广播", async () => { + const { result, unmount } = renderHook(() => useSubscribeDataManagement()); + await waitFor(() => expect(result.current.loadingList).toBe(false)); + + unmount(); + + expect(messageHandlers.has("upsertSubscribe")).toBe(false); + }); +}); diff --git a/src/pages/options/routes/SubscribeList/hooks.ts b/src/pages/options/routes/SubscribeList/hooks.ts index cb4707fa5..65ed35062 100644 --- a/src/pages/options/routes/SubscribeList/hooks.ts +++ b/src/pages/options/routes/SubscribeList/hooks.ts @@ -1,9 +1,13 @@ import { useEffect, useState } from "react"; import { fetchSubscribeList, type SubscribeLoading } from "@App/pages/store/features/subscribe"; +import { subscribeMessage } from "@App/pages/store/global"; +import type { TInstallSubscribe } from "@App/app/service/queue"; + +const sortByCreatetime = (list: SubscribeLoading[]) => [...list].sort((a, b) => a.createtime - b.createtime); /** - * 管理订阅数据的核心逻辑:挂载时拉取一次列表。 - * 订阅的启用/删除在页面侧做乐观更新(与 v1.4 一致,订阅服务端未广播列表变更)。 + * 管理订阅数据的核心逻辑:挂载时拉取一次列表,之后随后台 upsertSubscribe 广播同步新增/更新的订阅。 + * 订阅的启用/删除在页面侧做乐观更新(与 v1.4 一致,服务端不广播这两类变更)。 */ export function useSubscribeDataManagement() { const [subscribeList, setSubscribeList] = useState([]); @@ -15,7 +19,7 @@ export function useSubscribeDataManagement() { void fetchSubscribeList().then((list) => { if (!mounted) return; // 按创建时间升序,保证「#」序号稳定 - setSubscribeList([...list].sort((a, b) => a.createtime - b.createtime)); + setSubscribeList(sortByCreatetime(list)); setLoadingList(false); }); return () => { @@ -23,5 +27,17 @@ export function useSubscribeDataManagement() { }; }, []); + useEffect(() => { + return subscribeMessage("upsertSubscribe", ({ subscribe }) => { + setSubscribeList((list) => { + const idx = list.findIndex((s) => s.url === subscribe.url); + if (idx === -1) return sortByCreatetime([...list, subscribe]); + const newList = [...list]; + newList[idx] = { ...newList[idx], ...subscribe }; + return newList; + }); + }); + }, []); + return { subscribeList, setSubscribeList, loadingList }; } From 052ec4dc4455f07b5b35941eb88b7afd654f757e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:33:45 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20=E9=9D=99=E9=BB=98=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=BF=9D=E5=AD=98=E5=AE=8C=E6=88=90=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E5=9B=9E=E6=8A=A5=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/subscribe.test.ts | 37 ++++++++++++++++++- src/app/service/service_worker/subscribe.ts | 18 +++++---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/app/service/service_worker/subscribe.test.ts b/src/app/service/service_worker/subscribe.test.ts index 29a437d80..771e883aa 100644 --- a/src/app/service/service_worker/subscribe.test.ts +++ b/src/app/service/service_worker/subscribe.test.ts @@ -145,7 +145,7 @@ describe("SubscribeService —— 手动检查更新", () => { const mq = new MessageQueue(); const service = new SubscribeService(server.group("subscribe"), mq, {} as ScriptService); service.init(); - return { client: new SubscribeClient(mockMessage), fetchMock, tabsCreate, mq }; + return { client: new SubscribeClient(mockMessage), fetchMock, tabsCreate, mq, service }; }; it("远端版本更高且无需确认时应静默更新并返回 updated", async () => { @@ -160,6 +160,41 @@ describe("SubscribeService —— 手动检查更新", () => { expect(tabsCreate).not.toHaveBeenCalled(); }); + it("静默更新保存完成后才返回 updated", async () => { + const { client, service, mq } = await setup(subscribeCode("0.3.4")); + let finishSave!: (subscribe: Subscribe) => void; + const savePending = new Promise((resolve) => { + finishSave = resolve; + }); + const save = vi.spyOn(service.subscribeDAO, "save").mockReturnValue(savePending); + const installed = new Promise((resolve) => mq.subscribe("installSubscribe", resolve)); + let settled = false; + const result = client.checkUpdate(SUB_URL).then((value) => { + settled = true; + return value; + }); + + try { + await vi.waitFor(() => expect(save).toHaveBeenCalledOnce()); + expect(settled).toBe(false); + } finally { + finishSave(makeSubscribe({ metadata: { usersubscribe: [], version: ["0.3.4"] } })); + } + + await expect(result).resolves.toBe("updated"); + await expect(installed).resolves.toMatchObject({ subscribe: { metadata: { version: ["0.3.4"] } } }); + }); + + it("静默更新保存失败时返回 false", async () => { + const { client, service, tabsCreate } = await setup(subscribeCode("0.3.4")); + const failedInstall = Promise.reject(new Error("storage unavailable")); + void failedInstall.catch(() => {}); + vi.spyOn(service, "install").mockReturnValue(failedInstall); + + await expect(client.checkUpdate(SUB_URL)).resolves.toBe(false); + expect(tabsCreate).not.toHaveBeenCalled(); + }); + it("远端新增 @connect 域时应打开安装页并返回 confirm", async () => { const { client, tabsCreate } = await setup(subscribeCode("0.3.4", ["new.example.com"])); diff --git a/src/app/service/service_worker/subscribe.ts b/src/app/service/service_worker/subscribe.ts index c875cbfcd..61e5277c7 100644 --- a/src/app/service/service_worker/subscribe.ts +++ b/src/app/service/service_worker/subscribe.ts @@ -301,17 +301,19 @@ export class SubscribeService { const logger = this.logger.with({ url, }); + let newSubscribe: Awaited>; try { - const newSubscribe = await prepareSubscribeByCode(code, url); - if (checkSilenceUpdate(newSubscribe.oldSubscribe!.metadata, newSubscribe.subscribe.metadata)) { - logger.info("silence update subscribe"); - this.install({ - subscribe: newSubscribe.subscribe, - }); - return true; - } + newSubscribe = await prepareSubscribeByCode(code, url); } catch (e) { logger.error("prepare script failed", Logger.E(e)); + return; + } + if (checkSilenceUpdate(newSubscribe.oldSubscribe!.metadata, newSubscribe.subscribe.metadata)) { + logger.info("silence update subscribe"); + await this.install({ + subscribe: newSubscribe.subscribe, + }); + return true; } } From 946044fd7097f2942338244fc795af927043f8a9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:46:24 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20=E8=AE=A2=E9=98=85=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=BB=93=E6=9E=9C=E9=9A=8F=E8=AE=A2=E9=98=85=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=98=E5=8C=96=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/SubscribeList/components.test.tsx | 32 ++++++++++ .../routes/SubscribeList/components.tsx | 60 +++++++++++++------ 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/pages/options/routes/SubscribeList/components.test.tsx b/src/pages/options/routes/SubscribeList/components.test.tsx index fd400c8d6..98931bad8 100644 --- a/src/pages/options/routes/SubscribeList/components.test.tsx +++ b/src/pages/options/routes/SubscribeList/components.test.tsx @@ -86,4 +86,36 @@ describe("订阅更新时间格的检查更新状态", () => { expect(screen.queryByText(t("script:new_version_available"))).toBeNull(); }); + + it("检查期间订阅更新时间变化后忽略过期的确认结果", async () => { + let resolveCheck!: (result: "confirm") => void; + const pendingCheck = new Promise<"confirm">((resolve) => { + resolveCheck = resolve; + }); + vi.mocked(requestCheckSubscribeUpdate).mockReturnValueOnce(pendingCheck); + const { rerender } = render(renderCell(1000)); + + await clickCheck(); + rerender(renderCell(2000)); + + await act(async () => { + resolveCheck("confirm"); + await pendingCheck; + }); + + expect(screen.queryByText(t("script:new_version_available"))).toBeNull(); + }); + + it("订阅更新时间变化后重置已是最新状态", async () => { + vi.mocked(requestCheckSubscribeUpdate).mockResolvedValueOnce(false); + const { rerender } = render(renderCell(1000)); + + await clickCheck(); + expect(screen.getByText(t("script:latest_version"))).toBeInTheDocument(); + + rerender(renderCell(2000)); + + expect(screen.queryByText(t("script:latest_version"))).toBeNull(); + expect(screen.getByRole("button", { name: t("check_update") })).toBeInTheDocument(); + }); }); diff --git a/src/pages/options/routes/SubscribeList/components.tsx b/src/pages/options/routes/SubscribeList/components.tsx index cdc914d4d..0909717ee 100644 --- a/src/pages/options/routes/SubscribeList/components.tsx +++ b/src/pages/options/routes/SubscribeList/components.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { SubscribeStatusType } from "@App/app/repo/subscribe"; import { requestCheckSubscribeUpdate } from "@App/pages/store/features/subscribe"; import { Switch } from "@App/pages/components/ui/switch"; @@ -109,44 +109,68 @@ type CheckUpdateState = "idle" | "checking" | "latest" | "has-update"; export const SubscribeUpdateTimeCell = React.memo(({ url, updatetime }: { url: string; updatetime?: number }) => { const { t } = useTranslation(); - const [state, setState] = useState("idle"); + const [checkState, setCheckState] = useState({ status: "idle" as CheckUpdateState, generation: 0 }); + const requestGeneration = useRef(0); - // 用户在安装页确认更新后 updatetime 随广播变化,「存在新版本」已失效 - const [prevUpdatetime, setPrevUpdatetime] = useState(updatetime); - if (updatetime !== prevUpdatetime) { - setPrevUpdatetime(updatetime); - if (state === "has-update") setState("idle"); + // 更新结果属于检查时的订阅版本,URL 或更新时间变化后不能再应用旧结果 + const [prevIdentity, setPrevIdentity] = useState({ url, updatetime }); + if (url !== prevIdentity.url || updatetime !== prevIdentity.updatetime) { + setPrevIdentity({ url, updatetime }); + setCheckState((current) => ({ + status: "idle", + generation: current.generation + 1, + })); } + useLayoutEffect(() => { + requestGeneration.current = checkState.generation; + return () => { + if (requestGeneration.current === checkState.generation) requestGeneration.current++; + }; + }, [checkState.generation]); + const handleCheck = useCallback(() => { - if (state === "checking") return; - setState("checking"); + if (checkState.status === "checking") return; + const generation = checkState.generation + 1; + requestGeneration.current = generation; + setCheckState({ status: "checking", generation }); requestCheckSubscribeUpdate(url) // updated:已静默更新,新的更新时间随广播到达;confirm:已打开安装页待确认 - .then((res) => setState(res === "updated" ? "idle" : res === "confirm" ? "has-update" : "latest")) + .then((res) => { + if (requestGeneration.current !== generation) return; + setCheckState((current) => + current.generation === generation + ? { status: res === "updated" ? "idle" : res === "confirm" ? "has-update" : "latest", generation } + : current + ); + }) .catch((e) => { - setState("idle"); + if (requestGeneration.current !== generation) return; + setCheckState((current) => (current.generation === generation ? { status: "idle", generation } : current)); notify.error(`${t("script:update_check_failed")}: ${e}`); }); - }, [state, url, t]); + }, [checkState, url, t]); // 「已是最新」短暂提示后恢复默认 useEffect(() => { - if (state !== "latest") return; - const id = setTimeout(() => setState("idle"), 2000); + if (checkState.status !== "latest") return; + const generation = checkState.generation; + const id = setTimeout(() => { + setCheckState((current) => (current.generation === generation ? { status: "idle", generation } : current)); + }, 2000); return () => clearTimeout(id); - }, [state]); + }, [checkState.status, checkState.generation]); const time = updatetime ? semTime(new Date(updatetime)) : "-"; return (
- {state === "latest" ? ( + {checkState.status === "latest" ? ( {t("script:latest_version")} - ) : state === "has-update" ? ( + ) : checkState.status === "has-update" ? ( /* 检查到新版本:直接取代时间展示「存在新版本」入口,点击可再次触发更新 */ @@ -173,7 +197,7 @@ export const SubscribeUpdateTimeCell = React.memo(({ url, updatetime }: { url: s onClick={handleCheck} className="text-muted-foreground opacity-60 transition-opacity hover:text-foreground hover:opacity-100" > - + {t("check_update")} From b82813568c8660f42ce4adef26e5d36730bb0c85 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:04:38 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20=E7=A1=AE=E8=AE=A4=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=A1=B5=E6=89=93=E5=BC=80=E5=90=8E=E5=86=8D=E5=9B=9E?= =?UTF-8?q?=E6=8A=A5=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/subscribe.test.ts | 36 +++++++++++++++++++ src/app/service/service_worker/subscribe.ts | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/subscribe.test.ts b/src/app/service/service_worker/subscribe.test.ts index 771e883aa..a39b344f8 100644 --- a/src/app/service/service_worker/subscribe.test.ts +++ b/src/app/service/service_worker/subscribe.test.ts @@ -204,6 +204,42 @@ describe("SubscribeService —— 手动检查更新", () => { expect(tabsCreate).toHaveBeenCalledWith({ url: expect.stringContaining("/src/install.html?uuid=") }); }); + it("安装确认页创建完成后才返回 confirm", async () => { + const { client, tabsCreate } = await setup(subscribeCode("0.3.4", ["new.example.com"])); + let finishCreate!: (tab: chrome.tabs.Tab) => void; + const createPending = new Promise((resolve) => { + finishCreate = resolve; + }); + tabsCreate.mockImplementation(() => createPending); + let settled = false; + const result = client.checkUpdate(SUB_URL).then((value) => { + settled = true; + return value; + }); + + await vi.waitFor(() => expect(tabsCreate).toHaveBeenCalledOnce()); + expect(settled).toBe(false); + finishCreate({} as chrome.tabs.Tab); + + await expect(result).resolves.toBe("confirm"); + }); + + it("安装确认页创建失败时不返回 confirm", async () => { + const { client, tabsCreate } = await setup(subscribeCode("0.3.4", ["new.example.com"])); + let failCreate!: (error: Error) => void; + const createPending = new Promise((_, reject) => { + failCreate = reject; + }); + void createPending.catch(() => {}); + tabsCreate.mockImplementation(() => createPending); + + const result = client.checkUpdate(SUB_URL); + await vi.waitFor(() => expect(tabsCreate).toHaveBeenCalledOnce()); + failCreate(new Error("tab creation failed")); + + await expect(result).resolves.toBe(false); + }); + it("远端版本未升高时返回 false", async () => { const { client, tabsCreate } = await setup(subscribeCode("0.3.3")); diff --git a/src/app/service/service_worker/subscribe.ts b/src/app/service/service_worker/subscribe.ts index 61e5277c7..8e949dc0d 100644 --- a/src/app/service/service_worker/subscribe.ts +++ b/src/app/service/service_worker/subscribe.ts @@ -282,7 +282,7 @@ export class SubscribeService { savedAt: Date.now(), type: TempStorageItemType.tempCode, }); - chrome.tabs.create({ + await chrome.tabs.create({ url: `/src/install.html?uuid=${uuid}`, }); } From 0efaadac53a217746805fbae2da6e4817c1e861b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:33:37 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=B8=B2=E8=A1=8C=E5=8C=96?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E8=AE=A2=E9=98=85=E7=9A=84=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/subscribe.test.ts | 73 ++++++++++++++++++- src/app/service/service_worker/subscribe.ts | 48 ++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/app/service/service_worker/subscribe.test.ts b/src/app/service/service_worker/subscribe.test.ts index a39b344f8..26e0d384d 100644 --- a/src/app/service/service_worker/subscribe.test.ts +++ b/src/app/service/service_worker/subscribe.test.ts @@ -106,6 +106,77 @@ describe("SubscribeService —— 订阅列表同步广播", () => { expect(subscribe.url).toBe(SUB_URL); expect(subscribe.scripts[SCRIPT_URL]).toEqual({ url: SCRIPT_URL, uuid: "sub-script-1" }); }); + + it("较旧的脚本同步完成较晚时保留较新的订阅", async () => { + await new SubscribeDAO().save( + makeSubscribe({ + scripts: {}, + metadata: { usersubscribe: [], scripturl: [SCRIPT_URL], version: ["0.3.4"] }, + }) + ); + const { service, scriptService, mq } = buildService(); + let finishScriptInstall!: (script: Script) => void; + const scriptInstallPending = new Promise