Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`);
Expand Down
62 changes: 60 additions & 2 deletions src/app/service/service_worker/index.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"));
Expand All @@ -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 });
});
});
51 changes: 29 additions & 22 deletions src/app/service/service_worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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" });
Expand Down Expand Up @@ -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<chrome.tabs.Tab>
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);
}
});

Expand Down
85 changes: 84 additions & 1 deletion src/app/service/service_worker/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import {
isBase64,
parseUrlSRI,
Expand All @@ -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";
Expand Down Expand Up @@ -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<chrome.tabs.Tab> | 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"],
Expand Down
17 changes: 16 additions & 1 deletion src/app/service/service_worker/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -322,6 +322,8 @@ export const removeFavicon = (filename: string): Promise<void> => {

export type NotificationOptionCache = {
url?: string;
// 已为 url 打开过的标签页,点击通知时优先激活它而不是重复打开(#1769)
tabId?: number;
};

export async function InfoNotification(title: string, msg: string, options?: NotificationOptionCache) {
Expand All @@ -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 });
}
Loading