diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 853ebc3aa..b2c91d5d0 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -36,7 +36,7 @@ import type { } from "../types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; -import { BrowserNoSupport, getCombinedMeta, notificationsUpdate } from "../utils"; +import { BrowserNoSupport, getCombinedMeta, notificationsUpdate, openNotificationUrl } from "../utils"; import { getSkillScriptGrantsByUuid, getSkillScriptNameByUuid, @@ -1568,10 +1568,9 @@ export default class GMApi { if (options) { if (event === "click") { if (options.url) { - // 打开链接 - chrome.tabs.create({ - url: options.url, - }); + openNotificationUrl(options.url, options.tabId).catch((e) => + this.logger.error("open notification url failed", { url: options.url }, Logger.E(e)) + ); // 关闭通知 chrome.notifications.clear(notificationId); cacheInstance.del(`notification:${notificationId}:options`); diff --git a/src/app/service/service_worker/index.test.ts b/src/app/service/service_worker/index.test.ts index 83db844dd..e01694718 100644 --- a/src/app/service/service_worker/index.test.ts +++ b/src/app/service/service_worker/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeAll } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; import { initTestLanguage } from "@Tests/initTestLanguage"; import type { ExternalAccessWriteNotice } from "./external_access/bridge"; import type * as ServiceWorkerUtils from "./utils"; @@ -12,8 +12,9 @@ vi.mock("./utils", async (importOriginal) => { return { ...actual, InfoNotification: vi.fn() }; }); -import { notifyExternalAccessWrite } from "./index"; +import { notifyExternalAccessWrite, openChangelogAndNotify } from "./index"; import { InfoNotification } from "./utils"; +import type RuntimeLogger from "@App/app/logger/logger"; describe("外部接入「直接允许」写策略下的系统通知", () => { beforeAll(() => initTestLanguage("zh-CN")); @@ -32,3 +33,60 @@ describe("外部接入「直接允许」写策略下的系统通知", () => { expect(bodyOf({ kind: "source_disclosure", name: "签到助手" })).toBe("已对「签到助手」执行变更"); }); }); + +describe("扩展更新后的更新日志页面与系统通知", () => { + const url = "https://docs.scriptcat.org/docs/change/#1.5.0"; + const makeLogger = () => ({ error: vi.fn() }) as unknown as RuntimeLogger; + + beforeAll(() => initTestLanguage("zh-CN")); + beforeEach(() => vi.mocked(InfoNotification).mockClear()); + + // #1769:通知必须带上自动打开的标签页,点击时才能激活它而不是再开一个同样的页面 + it("自动打开更新日志时,通知应记录该标签页", async () => { + let created: chrome.tabs.CreateProperties | undefined; + const onCreate = (props: chrome.tabs.CreateProperties) => { + created = props; + }; + (chrome.tabs as any).hook.on("create", onCreate); + try { + await openChangelogAndNotify(url, true, makeLogger()); + } finally { + (chrome.tabs as any).hook.removeListener("create", onCreate); + } + + expect(created?.url).toBe(url); + // chrome-extension-mock 的 tabs.create 固定回传 id 为 1 的标签 + expect(vi.mocked(InfoNotification).mock.calls[0][2]).toEqual({ url, tabId: 1 }); + }); + + it("不自动打开更新日志时,只发通知且不记录标签页", async () => { + const onCreate = vi.fn(); + (chrome.tabs as any).hook.on("create", onCreate); + try { + await openChangelogAndNotify(url, false, makeLogger()); + } finally { + (chrome.tabs as any).hook.removeListener("create", onCreate); + } + + expect(onCreate).not.toHaveBeenCalled(); + expect(vi.mocked(InfoNotification).mock.calls[0][2]).toEqual({ url, tabId: undefined }); + }); + + it("打开更新日志失败时仍应发出通知,点击后再新开页面", async () => { + const originalChrome = globalThis.chrome; + const error = new Error("tabs.create failed"); + vi.stubGlobal("chrome", { + ...originalChrome, + tabs: { ...originalChrome.tabs, create: vi.fn().mockRejectedValue(error) }, + }); + const logger = makeLogger(); + try { + await openChangelogAndNotify(url, true, logger); + } finally { + vi.stubGlobal("chrome", originalChrome); + } + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(vi.mocked(InfoNotification).mock.calls[0][2]).toEqual({ url, tabId: undefined }); + }); +}); diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index dbe139672..75f20357b 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -58,6 +58,34 @@ export function notifyExternalAccessWrite(notice: ExternalAccessWriteNotice): vo void InfoNotification(t("external_access:allow_notify_title"), body); } +// 扩展更新后按需打开更新日志页面,并发出点击可查看更新日志的通知。 +// 先开页面再发通知:通知要记下自动打开的标签页,点击时才能激活它而不是再开一个同样的页面(#1769)。 +export async function openChangelogAndNotify(url: string, autoOpen: boolean, logger: RuntimeLogger): Promise { + const tab = autoOpen + ? await getCurrentTab() + .then((currentTab) => { + // 检查是否正在播放视频,或者窗口未激活 + const openInBackground = !currentTab || currentTab.audible === true || !currentTab.active; + return chrome.tabs.create({ + url, + active: !openInBackground, + index: !currentTab ? undefined : currentTab.index + 1, + windowId: !currentTab ? undefined : currentTab.windowId, + }); + }) + .catch((e) => { + // 打不开页面也照常发通知,点击通知时再新开 + logger.error("open extension changelog failed", { url }, RuntimeLogger.E(e)); + return undefined; + }) + : undefined; + await InfoNotification( + t("popup:ext_update_notification"), + t("popup:ext_update_notification_desc", { version: ExtVersion }), + { url, tabId: tab?.id } + ); +} + // service worker的管理器 export default class ServiceWorkerManager { private serviceLogger = LoggerCore.logger().with({ service: "service_worker" }); @@ -390,28 +418,7 @@ export default class ServiceWorkerManager { const url = `${DocumentationSite}${localePath}/docs/change/${ExtVersion.includes("-") ? "beta-changelog/" : ""}#${ExtVersion}`; // 如果只是修复版本,只弹出通知不打开页面 // beta版本还是每次都打开更新页面 - InfoNotification( - t("popup:ext_update_notification"), - t("popup:ext_update_notification_desc", { version: ExtVersion }), - { - url, - } - ); - if (shouldAutoOpenChangelog(ExtVersion)) { - getCurrentTab() - .then((tab) => { - // 检查是否正在播放视频,或者窗口未激活 - const openInBackground = !tab || tab.audible === true || !tab.active; - // chrome.tabs.create 传回 Promise - return chrome.tabs.create({ - url, - active: !openInBackground, - index: !tab ? undefined : tab.index + 1, - windowId: !tab ? undefined : tab.windowId, - }); - }) - .catch((e) => this.serviceLogger.error("open extension changelog failed", { url }, RuntimeLogger.E(e))); - } + void openChangelogAndNotify(url, shouldAutoOpenChangelog(ExtVersion), this.serviceLogger); } }); diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 9b3ce107b..c83c88854 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { isBase64, parseUrlSRI, @@ -8,6 +8,7 @@ import { compileInjectionCode, shouldAutoOpenChangelog, scriptURLPatternResults, + openNotificationUrl, } from "./utils"; import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; @@ -122,6 +123,88 @@ describe.concurrent("shouldAutoOpenChangelog", () => { }); }); +describe("openNotificationUrl", () => { + const changelogUrl = "https://docs.scriptcat.org/docs/change/#1.5.0"; + const originalChrome = globalThis.chrome; + + const stubTabs = (existing: Partial | undefined) => { + const api = { + get: vi.fn((tabId: number) => + existing?.id === tabId ? Promise.resolve(existing as chrome.tabs.Tab) : Promise.reject(new Error("No tab")) + ), + update: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue({ id: 99 }), + windowsUpdate: vi.fn().mockResolvedValue(undefined), + }; + vi.stubGlobal("chrome", { + ...originalChrome, + tabs: { ...originalChrome.tabs, get: api.get, update: api.update, create: api.create }, + windows: { update: api.windowsUpdate }, + }); + return api; + }; + + afterEach(() => { + vi.stubGlobal("chrome", originalChrome); + }); + + // #1769:扩展更新时已自动打开过更新日志,点击更新通知不应再开一个同样的页面 + it("记录的标签页仍停留在该页面时,应激活它并聚焦其窗口而不是新开", async () => { + const api = stubTabs({ id: 5, windowId: 7, url: "https://docs.scriptcat.org/docs/change/#1.5.0" }); + + await openNotificationUrl(changelogUrl, 5); + + expect(api.update).toHaveBeenCalledWith(5, { active: true }); + expect(api.windowsUpdate).toHaveBeenCalledWith(7, { focused: true }); + expect(api.create).not.toHaveBeenCalled(); + }); + + it("页面内跳转锚点只改变 hash,仍视为同一页面", async () => { + const api = stubTabs({ id: 5, windowId: 7, url: "https://docs.scriptcat.org/docs/change/#v1-5-0" }); + + await openNotificationUrl(changelogUrl, 5); + + expect(api.update).toHaveBeenCalledWith(5, { active: true }); + expect(api.create).not.toHaveBeenCalled(); + }); + + it("标签页仍在加载时以 pendingUrl 判断", async () => { + const api = stubTabs({ id: 5, windowId: 7, url: "", pendingUrl: changelogUrl }); + + await openNotificationUrl(changelogUrl, 5); + + expect(api.update).toHaveBeenCalledWith(5, { active: true }); + expect(api.create).not.toHaveBeenCalled(); + }); + + it("记录的标签页已被关闭时,应新开页面", async () => { + const api = stubTabs(undefined); + + await openNotificationUrl(changelogUrl, 5); + + expect(api.create).toHaveBeenCalledWith({ url: changelogUrl }); + expect(api.update).not.toHaveBeenCalled(); + }); + + it("记录的标签页已跳转到其他页面时,不应抢占它而是新开页面", async () => { + const api = stubTabs({ id: 5, windowId: 7, url: "https://example.com/" }); + + await openNotificationUrl(changelogUrl, 5); + + expect(api.create).toHaveBeenCalledWith({ url: changelogUrl }); + expect(api.update).not.toHaveBeenCalled(); + }); + + it("没有记录标签页时,应直接新开页面", async () => { + const api = stubTabs({ id: 5, windowId: 7, url: changelogUrl }); + + await openNotificationUrl(changelogUrl); + + expect(api.get).not.toHaveBeenCalled(); + expect(api.create).toHaveBeenCalledWith({ url: changelogUrl }); + }); +}); + describe.concurrent("getCombinedMeta", () => { const baseMetadata: SCMetadata = { name: ["Test Script"], diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index cb483c998..27bac8e61 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -1,7 +1,7 @@ export const BrowserNoSupport = new Error("browserNoSupport"); import type { SCMetadata, Script, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; -import { getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils"; +import { getMetadataStr, getTab, getUserConfigStr } from "@App/pkg/utils/utils"; import type { ScriptMatchInfo } from "./types"; import { compileInjectScript, @@ -322,6 +322,8 @@ export const removeFavicon = (filename: string): Promise => { export type NotificationOptionCache = { url?: string; + // 已为 url 打开过的标签页,点击通知时优先激活它而不是重复打开(#1769) + tabId?: number; }; export async function InfoNotification(title: string, msg: string, options?: NotificationOptionCache) { @@ -336,3 +338,16 @@ export async function InfoNotification(title: string, msg: string, options?: Not cacheInstance.set(`notification:${notificationId}:options`, options); } } + +// 点击带链接的通知:tabId 对应的标签页仍停留在该页面时激活它,否则新开。 +// 只比较 hash 之前的部分,页面内跳转锚点不算离开;标签页已关闭或跳到别处则不能抢占它。 +export async function openNotificationUrl(url: string, tabId?: number) { + const tab = tabId === undefined ? undefined : await getTab(tabId); + const tabUrl = tab?.pendingUrl || tab?.url; + if (tab?.id !== undefined && tabUrl && tabUrl.split("#")[0] === url.split("#")[0]) { + await chrome.tabs.update(tab.id, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + return; + } + await chrome.tabs.create({ url }); +}