diff --git a/docs/architecture.md b/docs/architecture.md index cbe6f5f7a..a05eebae7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,7 @@ if (hasOffscreenDocument) { new ServiceWorkerManager(server, messageQueue, offscreen).initManager(); setupOffscreenDocument(); // chrome.offscreen.createDocument(...) } else { - const offscreen = new EventPageOffscreenManager(message); // Firefox MV3: event page IS the DOM env + const offscreen = new EventPageOffscreenManager(message, messageQueue); // Firefox MV3: event page IS the DOM env new ServiceWorkerManager(server, messageQueue, offscreen).initManager(); } ``` @@ -119,8 +119,34 @@ already has DOM and plays the offscreen role directly. finds the offscreen client via `clients.matchAll()` and `postMessage`s it); Offscreen → SW replies over `ExtensionMessage` (`chrome.runtime`). - **Firefox:** [`EventPageOffscreenManager`](../src/app/service/offscreen/event_page_manager.ts) substitutes - for the offscreen document; `service_worker.ts` emits `preparationOffscreen` immediately because the DOM - environment is already live. + for the offscreen document; its sandbox iframe is a `sandbox` manifest page, which Firefox 154+ loads as a + cross-origin frame (`contentDocument` is `null`, `contentWindow.location` is unreadable). Only the sandbox + itself knows when it's actually ready, so the parent never polls or pings it: `SandboxManager` + ([`src/app/service/sandbox/index.ts`](../src/app/service/sandbox/index.ts)) proactively posts a + `preparationSandbox` message once its own `Server` is wired up (same mechanism on both platforms), and + [`BackgroundEnvManagerBase.preparationSandbox`](../src/app/service/offscreen/base.ts) immediately tells the + service worker `preparationOffscreen({ verified: true })` — no round trip, no waiting. The service worker + replays enabled background/scheduled scripts and the current language only for this verified signal, once. + Separately and non-blockingly, the + sandbox reuses its own in-flight `getExtensionEnv` request to self-check that the channel is genuinely + bidirectional, and reports the outcome via `reportSandboxChannelHealth`, which the parent logs (visible in + the parent's own console/log, since the sandbox iframe's console is far less discoverable). If the sandbox + never announces readiness at all (iframe failed to load, script error), a fallback timer in + `BackgroundEnvManagerBase` still tells the service worker `preparationOffscreen({ verified: false })` after + `SANDBOX_READY_FALLBACK_MS`, logging a clear error instead of hanging forever. That unverified notification + does not send initialization through an unavailable channel; if the real handshake arrives later, the + verified state replay still occurs exactly once. + +Firefox packages use `incognito: "spanning"`, so normal and private page scripts share one event page but retain +their own `sender.tab.incognito` value for global-switch checks, `@run-in`, and `GM_info.isIncognito`. Background +and scheduled scripts have no originating tab and run once in the shared event-page sandbox; tab-scoped +`normal-tabs` / `incognito-tabs` values therefore do not create or imply separate background instances. + +Firefox packages always list `webRequestBlocking` in `optional_permissions` for the experimental event-page +keep-alive loop. On Firefox with `scheduler.postTask`, users can enable that loop from the install prompt or +runtime settings when they use Background Scripts or Scheduled Scripts. The loop starts only while +`keep_ext_background_alive` is enabled and the optional permission is granted; permission add/remove events +start or stop probing at runtime. Services never see this difference: they receive an `IOffscreenSend` and call `.init()`. diff --git a/e2e/keep-alive.spec.ts b/e2e/keep-alive.spec.ts new file mode 100644 index 000000000..d2cc17f9c --- /dev/null +++ b/e2e/keep-alive.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from "./fixtures"; +import { openOptionsPage } from "./utils"; +import type { CDPSession } from "@playwright/test"; + +const KEEP_ALIVE_LABEL = "Keep Background and Scheduled Scripts Alive"; +const SERVICE_WORKER_URL = "/service_worker.js"; +const HEARTBEAT_VALIDATION_WINDOW_MS = 31_000; + +type CdpTargetMessage = { + sessionId: string; + message: string; +}; + +type CdpCommandResponse = { + id: number; + result?: { result?: { value?: number } }; + error?: { message?: string }; +}; + +const isServiceWorkerAlive = async (cdp: CDPSession) => { + const { targetInfos } = await cdp.send("Target.getTargets"); + return targetInfos.some((target) => target.type === "service_worker" && target.url.endsWith(SERVICE_WORKER_URL)); +}; + +const sendTargetCommand = async ( + cdp: CDPSession, + sessionId: string, + id: number, + method: string, + params: Record = {} +) => { + const response = new Promise((resolve, reject) => { + const listener = (event: CdpTargetMessage) => { + if (event.sessionId !== sessionId) return; + const message = JSON.parse(event.message) as CdpCommandResponse; + if (message.id !== id) return; + cdp.off("Target.receivedMessageFromTarget", listener); + if (message.error) { + reject(new Error(message.error.message ?? `CDP command failed: ${method}`)); + return; + } + resolve(message); + }; + cdp.on("Target.receivedMessageFromTarget", listener); + }); + + await cdp.send("Target.sendMessageToTarget", { + sessionId, + message: JSON.stringify({ id, method, params }), + }); + return response; +}; + +test.describe("Chrome MV3 service worker keep-alive", () => { + test("offscreen runtime heartbeat keeps the service worker active", async ({ context, extensionId }) => { + const optionsPage = await openOptionsPage(context, extensionId); + const cdp = await context.newCDPSession(optionsPage); + + try { + await optionsPage.goto(`chrome-extension://${extensionId}/src/options.html#/settings`); + const label = optionsPage.getByText(KEEP_ALIVE_LABEL, { exact: true }); + await label.scrollIntoViewIfNeeded(); + + const keepAliveSwitch = label.locator("xpath=../..").getByRole("switch"); + await expect(keepAliveSwitch).toBeVisible(); + await expect(keepAliveSwitch).toHaveAttribute("aria-checked", "false"); + + await expect + .poll( + async () => { + return isServiceWorkerAlive(cdp); + }, + { timeout: 15_000 } + ) + .toBe(true); + + await keepAliveSwitch.click(); + await expect(keepAliveSwitch).toHaveAttribute("aria-checked", "true"); + + const startedAt = Date.now(); + await expect + .poll( + async () => { + const serviceWorkerAlive = await isServiceWorkerAlive(cdp); + return Date.now() - startedAt >= HEARTBEAT_VALIDATION_WINDOW_MS && serviceWorkerAlive; + }, + { timeout: HEARTBEAT_VALIDATION_WINDOW_MS + 10_000, interval: 1_000 } + ) + .toBe(true); + } finally { + await optionsPage.close(); + } + }); + + test("disabling the setting allows the service worker to become idle", async ({ context, extensionId }) => { + const optionsPage = await openOptionsPage(context, extensionId); + const cdp = await context.newCDPSession(optionsPage); + let offscreenSessionId: string | undefined; + let nextCommandId = 1; + + try { + await optionsPage.goto(`chrome-extension://${extensionId}/src/options.html#/settings`); + const label = optionsPage.getByText(KEEP_ALIVE_LABEL, { exact: true }); + await label.scrollIntoViewIfNeeded(); + + const keepAliveSwitch = label.locator("xpath=../..").getByRole("switch"); + await expect(keepAliveSwitch).toBeVisible(); + await expect(keepAliveSwitch).toHaveAttribute("aria-checked", "false"); + + await expect + .poll( + async () => { + const { targetInfos } = await cdp.send("Target.getTargets"); + return targetInfos.some((target) => target.url.endsWith("/src/offscreen.html")); + }, + { timeout: 15_000 } + ) + .toBe(true); + + const { targetInfos } = await cdp.send("Target.getTargets"); + const offscreenTarget = targetInfos.find((target) => target.url.endsWith("/src/offscreen.html")); + expect(offscreenTarget).toBeDefined(); + ({ sessionId: offscreenSessionId } = await cdp.send("Target.attachToTarget", { + targetId: offscreenTarget!.targetId, + flatten: false, + })); + await sendTargetCommand(cdp, offscreenSessionId, nextCommandId++, "Runtime.enable"); + await sendTargetCommand(cdp, offscreenSessionId, nextCommandId++, "Runtime.evaluate", { + expression: `(() => { + globalThis.__scriptcatKeepAliveHeartbeatCount = 0; + const originalConnect = chrome.runtime.connect.bind(chrome.runtime); + chrome.runtime.connect = (connectInfo) => { + const port = originalConnect(connectInfo); + if (connectInfo?.name !== "scriptcat-keep-alive") return port; + const originalPostMessage = port.postMessage.bind(port); + port.postMessage = (message) => { + globalThis.__scriptcatKeepAliveHeartbeatCount += 1; + return originalPostMessage(message); + }; + return port; + }; + })()`, + }); + + await keepAliveSwitch.click(); + await expect(keepAliveSwitch).toHaveAttribute("aria-checked", "true"); + const heartbeatCount = () => + sendTargetCommand(cdp, offscreenSessionId!, nextCommandId++, "Runtime.evaluate", { + expression: "globalThis.__scriptcatKeepAliveHeartbeatCount", + returnByValue: true, + }).then((message) => message.result?.result?.value ?? 0); + await expect.poll(heartbeatCount, { timeout: 15_000 }).toBeGreaterThan(0); + + await keepAliveSwitch.click(); + await expect(keepAliveSwitch).toHaveAttribute("aria-checked", "false"); + + const countAfterDisable = await heartbeatCount(); + const stoppedAt = Date.now(); + await expect + .poll( + async () => { + return ( + Date.now() - stoppedAt >= HEARTBEAT_VALIDATION_WINDOW_MS && (await heartbeatCount()) === countAfterDisable + ); + }, + { timeout: HEARTBEAT_VALIDATION_WINDOW_MS + 10_000, interval: 1_000 } + ) + .toBe(true); + } finally { + if (offscreenSessionId) { + await cdp.send("Target.detachFromTarget", { sessionId: offscreenSessionId }); + } + if (!optionsPage.isClosed()) await optionsPage.close(); + } + }); +}); diff --git a/packages/chrome-extension-mock/index.ts b/packages/chrome-extension-mock/index.ts index 4d2727dd8..6122b4f96 100644 --- a/packages/chrome-extension-mock/index.ts +++ b/packages/chrome-extension-mock/index.ts @@ -28,6 +28,8 @@ const chromeMock = { action: new Action(), init() { this.downloads.reset(); + this.permissions.reset(); + this.webRequest.reset(); }, }; diff --git a/packages/chrome-extension-mock/permissions.ts b/packages/chrome-extension-mock/permissions.ts index 17a9258ba..e4b75f3ef 100644 --- a/packages/chrome-extension-mock/permissions.ts +++ b/packages/chrome-extension-mock/permissions.ts @@ -1,6 +1,60 @@ +import EventEmitter from "eventemitter3"; + +class PermissionsEvent { + private EE = new EventEmitter(); + + addListener(callback: (permissions: chrome.permissions.Permissions) => void) { + this.EE.addListener("permission", callback); + } + + removeListener(callback: (permissions: chrome.permissions.Permissions) => void) { + this.EE.removeListener("permission", callback); + } + + hasListener(callback: (permissions: chrome.permissions.Permissions) => void) { + return this.EE.listeners("permission").includes(callback); + } + + emit(permissions: chrome.permissions.Permissions) { + this.EE.emit("permission", permissions); + } + + reset() { + this.EE.removeAllListeners(); + } +} + export default class Permissions { + onAdded = new PermissionsEvent(); + onRemoved = new PermissionsEvent(); + + private grantedPermissions: Set | null = null; + private grantedOrigins: Set | null = null; + + __setGrantedPermissions(permissions: string[], origins: string[] = []) { + this.grantedPermissions = new Set(permissions); + this.grantedOrigins = new Set(origins); + } + + reset() { + this.grantedPermissions = null; + this.grantedOrigins = null; + this.onAdded.reset(); + this.onRemoved.reset(); + } + request(permissions: chrome.permissions.Permissions, callback?: (granted: boolean) => void) { - // Mock implementation - always grant permissions for testing + if (this.grantedPermissions) { + for (const permission of permissions.permissions || []) { + this.grantedPermissions.add(permission); + } + } + if (this.grantedOrigins) { + for (const origin of permissions.origins || []) { + this.grantedOrigins.add(origin); + } + } + this.onAdded.emit(permissions); if (callback) { callback(true); } @@ -8,17 +62,23 @@ export default class Permissions { } contains(permissions: chrome.permissions.Permissions, callback?: (result: boolean) => void) { - // Mock implementation - always return true for testing + const result = + this.grantedPermissions === null && this.grantedOrigins === null + ? true + : (permissions.permissions || []).every((permission) => this.grantedPermissions?.has(permission)) && + (permissions.origins || []).every((origin) => this.grantedOrigins?.has(origin)); if (callback) { - callback(true); + callback(result); } - return Promise.resolve(true); + return Promise.resolve(result); } getAll(callback?: (permissions: chrome.permissions.Permissions) => void) { const mockPermissions = { - permissions: ["activeTab" as chrome.runtime.ManifestPermissions, "storage" as chrome.runtime.ManifestPermissions], - origins: [""], + permissions: (this.grantedPermissions + ? [...this.grantedPermissions] + : ["activeTab", "storage"]) as chrome.runtime.ManifestPermissions[], + origins: this.grantedOrigins ? [...this.grantedOrigins] : [""], }; if (callback) { callback(mockPermissions); @@ -27,7 +87,17 @@ export default class Permissions { } remove(permissions: chrome.permissions.Permissions, callback?: (removed: boolean) => void) { - // Mock implementation - always succeed for testing + if (this.grantedPermissions) { + for (const permission of permissions.permissions || []) { + this.grantedPermissions.delete(permission); + } + } + if (this.grantedOrigins) { + for (const origin of permissions.origins || []) { + this.grantedOrigins.delete(origin); + } + } + this.onRemoved.emit(permissions); if (callback) { callback(true); } diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index 5427cf21e..be576dcc7 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -31,12 +31,34 @@ export default class WebRequest { onBeforeRequest = { counter: 0, EE: new EventEmitter(), - addListener: function (callback: (...args: any[]) => any) { + listeners: [] as { + callback: (...args: any[]) => any; + filter?: chrome.webRequest.RequestFilter; + extraInfoSpec?: string[]; + }[], + addListener: function ( + callback: (...args: any[]) => any, + filter?: chrome.webRequest.RequestFilter, + extraInfoSpec?: string[] + ) { + this.listeners.push({ callback, filter, extraInfoSpec }); this.EE.addListener("onBeforeRequest", (params) => { callback(params); }); // TODO }, + removeListener: function (callback: (...args: any[]) => any) { + this.listeners = this.listeners.filter((listener) => listener.callback !== callback); + this.EE.removeAllListeners("onBeforeRequest"); + for (const listener of this.listeners) { + this.EE.addListener("onBeforeRequest", (params) => { + listener.callback(params); + }); + } + }, + hasListener: function (callback: (...args: any[]) => any) { + return this.listeners.some((listener) => listener.callback === callback); + }, }; onBeforeRedirect = { @@ -50,4 +72,12 @@ export default class WebRequest { // TODO }, }; + + reset() { + this.sendHeader = undefined; + this.responseStarted = undefined; + this.onBeforeRequest.counter = 0; + this.onBeforeRequest.listeners = []; + this.onBeforeRequest.EE.removeAllListeners(); + } } diff --git a/packages/message/message_queue.test.ts b/packages/message/message_queue.test.ts index 67669fb55..9db04729f 100644 --- a/packages/message/message_queue.test.ts +++ b/packages/message/message_queue.test.ts @@ -198,6 +198,45 @@ describe("MessageQueueGroup", () => { }); }); + it("publish 在没有接收方时(Firefox 下 sendMessage 返回的 Promise 会 reject)会主动 catch 住该 rejection", () => { + // chrome.runtime.sendMessage() 不带回调时返回 Promise。Chrome 在没有其它监听方时该 Promise + // 不会 reject,但 Firefox 会 reject 并抛出 "Could not establish connection. Receiving end + // does not exist."。publish 广播给"任何在监听的人",没人监听是正常情况,不应表现为报错, + // 也不应留下未处理的 Promise rejection。 + // 直接断言 .catch() 是否被调用,而不是依赖 process 的 unhandledRejection 事件—— + // 后者的触发时机取决于 Node 事件循环细节,在测试环境下并不可靠。 + const group = messageQueue.group("api-publishNoReceiver"); + const rejectedPromise = Promise.reject( + new Error("Could not establish connection. Receiving end does not exist.") + ); + const originalCatch = rejectedPromise.catch.bind(rejectedPromise); + let caughtCalled = false; + rejectedPromise.catch = (onRejected: any) => { + caughtCalled = true; + return originalCatch(onRejected); + }; + vi.spyOn(chrome.runtime, "sendMessage").mockImplementation(() => rejectedPromise as unknown as void); + + expect(() => group.publish("test-publishNoReceiver", { data: 1 })).not.toThrow(); + + expect(caughtCalled).toBe(true); + }); + + it("publish 遇到非无人接收类异常时记录 error 而不是静默降为 debug", async () => { + const group = messageQueue.group("api-publishUnexpectedError"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(chrome.runtime, "sendMessage").mockReturnValue(Promise.reject(new Error("transport exploded")) as never); + + group.publish("test-publishUnexpectedError", { data: 1 }); + await nextTick(); + await nextTick(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Unable to execute runtime.sendMessage"), + expect.anything() + ); + }); + it("emit 方法应该只在本地发布", () => { const group = messageQueue.group("api-emitLocal"); const handler = vi.fn(); diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts index 9864c2523..b681b0c73 100644 --- a/packages/message/message_queue.ts +++ b/packages/message/message_queue.ts @@ -73,13 +73,28 @@ export class MessageQueue implements IMessageQueue { } publish(topic: string, message: NonNullable) { - chrome.runtime.sendMessage({ - msgQueue: topic, - data: { action: "message", message }, - }); + // chrome.runtime.sendMessage() 不带回调时返回 Promise。没有其它上下文在监听(例如尚未打开 + // popup/options,或没有已注入的 content script)是完全正常的广播场景,不代表出错。 + // Chrome 在这种情况下该 Promise 不会 reject;但 Firefox 会 reject 并抛出 + // "Could not establish connection. Receiving end does not exist."——不接住就会变成 + // 未处理的 Promise rejection。publish 本身是"广播给任何在监听的人",无人监听应静默忽略。 + const messageQueueLogger = LoggerCore.getInstance().logger({ service: "messageQueue" }); + chrome.runtime + .sendMessage({ + msgQueue: topic, + data: { action: "message", message }, + }) + .catch((e) => { + const msg = JSON.stringify(e?.message || e); + if (msg.includes("Could not establish connection. Receiving end does not exist.")) { + messageQueueLogger.debug("No target audience for .publish", { msg }); + } else { + messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { msg }); + } + }); this.EE.emit(topic, message); //@ts-ignore - LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message }); + messageQueueLogger.trace("publish", { topic, message }); } // 只发布给当前环境 diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 358e7f33f..6f3d636b4 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -163,6 +163,26 @@ describe("ServiceWorkerClientMessage", () => { expect((clientMsg as any).sw).not.toBeNull(); expect((clientMsg as any).sw.postMessage).toBe(readyPostMessage); }); + + it("可接收 service worker 主动发送的请求并交给 Server 处理", () => { + const clientMsg = new ServiceWorkerClientMessage(); + const server = new Server("offscreen", clientMsg); + const keepAlive = vi.fn((enabled: boolean) => enabled); + server.on("keepAlive", keepAlive); + const source = { postMessage: vi.fn() }; + + clientMsg.messageHandle( + { messageId: "keep-alive-1", type: "sendMessage", data: { action: "offscreen/keepAlive", data: true } }, + source + ); + + expect(keepAlive).toHaveBeenCalledWith(true, expect.anything()); + expect(source.postMessage).toHaveBeenCalledWith({ + messageId: "keep-alive-1", + type: "respMessage", + data: { code: 0, data: true }, + }); + }); }); describe("WindowMessage.connect", () => { @@ -197,6 +217,102 @@ describe("WindowMessage.connect", () => { }); }); +// 单测重点:target 支持传入惰性求值函数,避免在 Firefox sandbox iframe 尚处于初始 about:blank +// 阶段就缓存 contentWindow 快照——导航到真正的 sandbox 页面后,浏览器是否仍保证该快照与 +// 事件的 e.source 全等属于实现细节,不可依赖;每次发送/比对都应重新读取当前值。 +describe("WindowMessage 惰性 target(懒解析)", () => { + it("target 为函数时,每次发送都重新读取当前返回值,而非构造时的快照", () => { + const sourceWindow = { addEventListener: vi.fn() } as unknown as Window; + + // 模拟 iframe 导航前(about:blank)与导航后(真正的 sandbox 页面)两个不同的 Window 引用 + const blankPostMessage = vi.fn(); + const realPostMessage = vi.fn(); + let current: Window = { postMessage: blankPostMessage } as unknown as Window; + + const wm = new WindowMessage(sourceWindow, () => current); + + wm.sendMessage({ action: "before-nav", data: 1 }); + expect(blankPostMessage).toHaveBeenCalledTimes(1); + expect(realPostMessage).not.toHaveBeenCalled(); + + // 模拟导航完成:iframe 现在指向真正的 sandbox 页面 + current = { postMessage: realPostMessage } as unknown as Window; + + wm.sendMessage({ action: "after-nav", data: 2 }); + expect(realPostMessage).toHaveBeenCalledTimes(1); + }); + + it("接收消息时按当前(而非构造时)读取的 target 比对 e.source,导航后不会误丢消息", () => { + let messageHandler: ((e: any) => void) | null = null; + const sourceWindow = { + addEventListener: vi.fn((event: string, handler: any) => { + if (event === "message") messageHandler = handler; + }), + } as unknown as Window; + + // 构造时若立即读取 contentWindow 会拿到 about:blank 的引用; + // 这里用函数延迟到真正需要比对时才读取,模拟构造之后才完成导航的时序 + const blankWindow = {} as unknown as Window; + const realWindow = {} as unknown as Window; + let current: Window = blankWindow; + + const wm = new WindowMessage(sourceWindow, () => current); + + // 模拟导航完成 + current = realWindow; + + const handler = vi.fn(); + wm.onMessage(handler); + + // 真正导航后的 sandbox 页面发出的消息,其 e.source 就是 realWindow + messageHandler!({ + source: realWindow, + data: { messageId: "m1", type: "sendMessage", data: { action: "ping" } }, + }); + + expect(handler.mock.calls[0]?.[0]).toEqual({ action: "ping" }); + }); + + it("target 解析抛错时不让异常冒出事件回调,且不影响后续消息的正常处理", () => { + let messageHandler: ((e: any) => void) | null = null; + const sourceWindow = { + addEventListener: vi.fn((event: string, handler: any) => { + if (event === "message") messageHandler = handler; + }), + } as unknown as Window; + + // 模拟 sandbox iframe 已从 DOM 移除:contentWindow 变为 null,getTarget() 因此抛错 + let shouldThrow = true; + const realWindow = {} as unknown as Window; + const wm = new WindowMessage(sourceWindow, () => { + if (shouldThrow) { + throw new Error("contentWindow is null"); + } + return realWindow; + }); + + const handler = vi.fn(); + wm.onMessage(handler); + + // target 解析失败期间收到的消息:不应抛出未捕获异常,消息被安全丢弃 + expect(() => + messageHandler!({ + source: realWindow, + data: { messageId: "m1", type: "sendMessage", data: { action: "dropped" } }, + }) + ).not.toThrow(); + expect(handler).not.toHaveBeenCalled(); + + // target 恢复可用后,后续消息应正常处理(证明前一次异常没有破坏监听器本身) + shouldThrow = false; + messageHandler!({ + source: realWindow, + data: { messageId: "m2", type: "sendMessage", data: { action: "recovered" } }, + }); + expect(handler.mock.calls[0]?.[0]).toEqual({ action: "recovered" }); + }); +}); + describe("ServiceWorkerMessageSend ↔ ServiceWorkerClientMessage 双向通信", () => { // 辅助函数: 将两端连接起来,模拟 postMessage 通道 function createWiredPair() { diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index cc22fce5a..9946bd089 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -1,7 +1,6 @@ import type { Message, MessageConnect, - MessageSend, OnConnectCallback, OnMessageCallback, RuntimeMessageSender, @@ -36,17 +35,36 @@ export type WindowMessageBody = { export class WindowMessage implements Message { EE = new EventEmitter(); + private readonly getTarget: () => Window; + // source: Window 消息来源 - // target: Window 消息目标 + // target: 消息目标。可传入 Window,也可传入一个惰性求值的函数。 + // 后者用于类似 Firefox sandbox iframe 的场景:iframe 刚创建时 contentWindow 指向初始的 + // about:blank 文档,若在此刻就缓存这个引用,后续导航到真正的 sandbox 页面后, + // 该引用是否仍与事件的 e.source 全等取决于浏览器实现,不可依赖。传入函数可在每次 + // 发送/比对时都重新读取 iframe.contentWindow,天然避免这类过早缓存的问题。 constructor( private source: Window, - private target: Window, + target: Window | (() => Window), private serviceWorker?: boolean ) { + this.getTarget = typeof target === "function" ? target : () => target; // 监听消息 this.source.addEventListener("message", (e) => { - if (e.source === this.target || e.source === this.source) { - this.messageHandle(e.data, new WindowPostMessage(this.target)); + // target 可能是惰性求值的,解析可能失败(例如 Firefox sandbox iframe 已从 DOM 移除)。 + // 用 try/catch 包住,避免这次失败连累了 e.source === this.source 的判断—— + // 两者应是互相独立的匹配条件,前者解析失败不代表后者也不成立。 + let target: Window | undefined; + try { + target = this.getTarget(); + } catch { + target = undefined; + } + if (target === undefined) { + return; // 无法确定回应目标,放弃处理这条消息,而不是让异常冒出事件回调 + } + if (e.source === target || e.source === this.source) { + this.messageHandle(e.data, new WindowPostMessage(target)); } }); // 是否监听serviceWorker消息 @@ -99,10 +117,11 @@ export class WindowMessage implements Message { type: "connect", data, }; - this.target.postMessage(body, "*"); + const target = this.getTarget(); + target.postMessage(body, "*"); // 使用 WindowPostMessage 包装,确保后续 sendMessage 也带 "*" targetOrigin // 否则沙箱(origin: null)→ offscreen(origin: chrome-extension://)的消息会被丢弃 - resolve(new WindowMessageConnect(body.messageId, this.EE, new WindowPostMessage(this.target))); + resolve(new WindowMessageConnect(body.messageId, this.EE, new WindowPostMessage(target))); }); } @@ -127,7 +146,7 @@ export class WindowMessage implements Message { resolve!(body.data as T); resolve = null; // 设为 null 提醒JS引擎可以GC }); - this.target.postMessage(body, "*"); + this.getTarget().postMessage(body, "*"); }); } } @@ -316,7 +335,7 @@ export class ServiceWorkerMessageSend implements Message { // 与ServiceWorkerMessageSend配对使用,实现Offscreen→SW的postMessage通道 // 注意: 扩展offscreen页面的navigator.serviceWorker.controller通常为null, // 需要通过navigator.serviceWorker.ready获取registration.active -export class ServiceWorkerClientMessage implements MessageSend { +export class ServiceWorkerClientMessage implements Message { EE = new EventEmitter(); private sw: ServiceWorker | null = null; @@ -324,7 +343,7 @@ export class ServiceWorkerClientMessage implements MessageSend { constructor() { navigator.serviceWorker.addEventListener("message", (e) => { - this.messageHandle(e.data); + this.messageHandle(e.data, e.source as PostMessage); }); // controller在扩展offscreen页面中通常为null,通过ready获取active this.sw = navigator.serviceWorker.controller; @@ -338,9 +357,18 @@ export class ServiceWorkerClientMessage implements MessageSend { } } - messageHandle(data: WindowMessageBody) { + messageHandle(data: WindowMessageBody, source?: PostMessage) { // 只处理响应类消息,请求类消息由WindowMessage处理 - if (data.type === "respMessage") { + if (data.type === "sendMessage" && source) { + this.EE.emit( + "message", + data.data, + (resp: any) => source.postMessage({ messageId: data.messageId, type: "respMessage", data: resp }), + {} as RuntimeMessageSender + ); + } else if (data.type === "connect" && source) { + this.EE.emit("connect", data.data, new WindowMessageConnect(data.messageId, this.EE, source)); + } else if (data.type === "respMessage") { this.EE.emit(`response:${data.messageId}`, data); } else if (data.type === "disconnect") { this.EE.emit(`disconnect:${data.messageId}`); @@ -349,6 +377,14 @@ export class ServiceWorkerClientMessage implements MessageSend { } } + onMessage(callback: OnMessageCallback): void { + this.EE.addListener("message", callback); + } + + onConnect(callback: OnConnectCallback): void { + this.EE.addListener("connect", callback); + } + private postToServiceWorker(message: any) { if (this.sw) { this.sw.postMessage(message); diff --git a/rspack.config.ts b/rspack.config.ts index 55470a397..2b66846f6 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -155,9 +155,11 @@ export default { manifest.name = "__MSG_scriptcat_beta__"; } if (isReactTools) { - manifest.content_security_policy = { - extension_pages: "script-src 'self' http://localhost:8097; object-src 'self'", - }; + if (!manifest.content_security_policy) { + manifest.content_security_policy = {}; + } + manifest.content_security_policy.extension_pages = + "script-src 'self' http://localhost:8097; object-src 'self'"; } return JSON.stringify(manifest); }, diff --git a/scripts/pack.js b/scripts/pack.js index 4fe465fe1..5ccbeae67 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -11,9 +11,8 @@ import { resolveAgentEnabled, applyAgentManifest } from "./build-config.js"; // ============================================================================ -// 目前 ScriptCat MV3 未正式支持 Firefox, -// 测试人员可修改 PACK_FIREFOX 为 true 作个人测试用途 -const PACK_FIREFOX = false; +// ScriptCat MV3 正式支持 Firefox +const PACK_FIREFOX = true; // ============================================================================ @@ -67,16 +66,40 @@ execSync("pnpm run build", { // 处理firefox和chrome的zip压缩包 // 浅拷贝防止后续修改 -const firefoxManifest = applyAgentManifest({ ...manifest, background: { ...manifest.background } }, agentEnabled); -const chromeManifest = applyAgentManifest({ ...manifest, background: { ...manifest.background } }, agentEnabled); +const cloneManifest = () => + applyAgentManifest( + { + ...manifest, + background: { ...manifest.background }, + content_security_policy: { ...manifest.content_security_policy }, + }, + agentEnabled + ); +const firefoxManifest = cloneManifest(); +const chromeManifest = cloneManifest(); chromeManifest.optional_permissions = chromeManifest.optional_permissions.filter((val) => val !== "userScripts"); delete chromeManifest.background.scripts; +// In Firefox, userScripts is an optional-only permission. It must appear only in optional_permissions, not both arrays. +// Firefox does not implement Chrome’s debugger extension API, so remove "debugger" from the Firefox manifest and disable any code using chrome.debugger. +// Firefox does not use Chrome’s offscreen permission/API. Your Firefox background.scripts runs in a document-based background context with a window, so DOM-related work can generally run there instead. +firefoxManifest.permissions = firefoxManifest.permissions.filter( + (val) => val !== "userScripts" && val !== "debugger" && val !== "offscreen" +); + +// Firefox manifest 始终声明该可选权限,运行时开关启用时再向用户请求授权。 +if (!firefoxManifest.optional_permissions.includes("webRequestBlocking")) { + firefoxManifest.optional_permissions.push("webRequestBlocking"); +} + // Firefox MV3 不支持 "background" permission firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); delete firefoxManifest.background.service_worker; -delete firefoxManifest.sandbox; + +// Firefox does not support "incognito": "split". Use "spanning", or use "not_allowed" when the extension must never access private windows. Private-window access is still controlled by the Firefox user. +firefoxManifest.incognito = "spanning"; + // Firefox 的扩展消息默认即为 structured clone,该键仅 Chromium 148+ 识别 delete firefoxManifest.message_serialization; firefoxManifest.browser_specific_settings = { @@ -86,7 +109,9 @@ firefoxManifest.browser_specific_settings = { }}`, // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts#browser_compatibility // Firefox 136 (Released 2025-03-04) - strict_min_version: "136.0", + // sandbox manifest: https://phabricator.services.mozilla.com/D308216 + // Firefox 154.0a1 (Nightly Released 2026-07-07) + strict_min_version: "154.0a1", data_collection_permissions: { required: [ "none", // 没有必须传送至第三方的资料。安装转页没有记录用户何时何地安装了什么。 diff --git a/src/app/service/extension/extension_env.test.ts b/src/app/service/extension/extension_env.test.ts index 150475902..768d59724 100644 --- a/src/app/service/extension/extension_env.test.ts +++ b/src/app/service/extension/extension_env.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { extensionEnv, getExtensionUserAgentData } from "./extension_env"; describe("extensionEnv 常量", () => { - it("从 chrome.extension.inIncognitoContext 读取 incognito 状态", () => { + it("读取扩展上下文状态与 manifest 的 incognito 模式", () => { // mock 默认 inIncognitoContext = false expect(extensionEnv.inIncognitoContext).toBe(false); + expect(extensionEnv).toHaveProperty("incognitoMode"); + expect(extensionEnv.incognitoMode).toBe(chrome.runtime.getManifest().incognito); // userAgentData 为可选字段,常量初始化时不应填充 expect(extensionEnv.userAgentData).toBeUndefined(); }); diff --git a/src/app/service/extension/extension_env.ts b/src/app/service/extension/extension_env.ts index e2108580c..c033c07ce 100644 --- a/src/app/service/extension/extension_env.ts +++ b/src/app/service/extension/extension_env.ts @@ -1,5 +1,6 @@ export type TExtensionEnv = { inIncognitoContext: boolean; + incognitoMode?: chrome.runtime.ManifestV3["incognito"]; userAgentData?: GMUserAgentData | null; }; @@ -7,6 +8,7 @@ type GMUserAgentData = typeof GM_info.userAgentData; export const extensionEnv: TExtensionEnv = { inIncognitoContext: chrome.extension.inIncognitoContext, + incognitoMode: chrome.runtime.getManifest().incognito, } satisfies TExtensionEnv; export const getExtensionUserAgentData = async (): Promise => { diff --git a/src/app/service/offscreen/base.test.ts b/src/app/service/offscreen/base.test.ts new file mode 100644 index 000000000..886ae3d4c --- /dev/null +++ b/src/app/service/offscreen/base.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { MockMessage } from "@Packages/message/mock_message"; +import { Server } from "@Packages/message/server"; +import EventEmitter from "eventemitter3"; +import type { WindowMessage } from "@Packages/message/window_message"; +import type { ServiceWorkerClient } from "../service_worker/client"; +import type { MessageSend, TMessage } from "@Packages/message/types"; +import { BackgroundEnvManagerBase, SANDBOX_READY_FALLBACK_MS } from "./base"; +import { MessageQueueGroup, type IMessageQueue } from "@Packages/message/message_queue"; +import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_BACKGROUND, type ScriptRunResource } from "@App/app/repo/scripts"; + +initTestEnv(); + +const buildManager = () => { + const bus = new MockMessage(new EventEmitter()); + const offscreenServer = new Server("offscreen", bus); + const preparationOffscreen = vi.fn(); + const serviceWorker = { preparationOffscreen } as unknown as ServiceWorkerClient; + const manager = new BackgroundEnvManagerBase( + {} as MessageSend, + bus as unknown as WindowMessage, + offscreenServer, + serviceWorker + ); + return { bus, offscreenServer, preparationOffscreen, manager }; +}; + +class LocalMessageQueue implements IMessageQueue { + private readonly events = new EventEmitter(); + + group(name: string, middleware?: Parameters[1]) { + return new MessageQueueGroup(this, name, middleware); + } + + subscribe(topic: string, handler: (message: T) => void) { + this.events.on(topic, handler); + return () => this.events.off(topic, handler); + } + + publish(topic: string, message: NonNullable) { + this.events.emit(topic, message); + } + + emit(topic: string, message: NonNullable) { + this.events.emit(topic, message); + } +} + +const flushAsyncHandlers = async () => { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +}; + +// 单测重点:就绪信号完全由 sandbox 主动上报(preparationSandbox),父层不 ping、不轮询、不猜测; +// sandbox 自行做的通道自检结果通过 reportSandboxChannelHealth 单独上报并记录到父层日志; +// 若 sandbox 从未上报任何消息,兜底超时仍会放行,且不会与真实握手产生重复通知 +describe("BackgroundEnvManagerBase 就绪握手", () => { + let infoSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + infoSpy.mockRestore(); + errorSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("sandbox 主动上报就绪后,立即通知 SW 就绪(不等待、不 ping)", () => { + const { manager, preparationOffscreen } = buildManager(); + + manager.preparationSandbox(); + + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + expect(preparationOffscreen).toHaveBeenCalledWith({ verified: true }); + }); + + it("sandbox 上报通道自检成功时,记录通过日志", () => { + const { manager } = buildManager(); + + manager.reportSandboxChannelHealth({ ok: true, roundTripMs: 12 }); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("communication verified"), expect.anything()); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("sandbox 上报通道自检失败时,记录失败日志(附带 sandbox 给出的具体原因)", () => { + const { manager } = buildManager(); + + manager.reportSandboxChannelHealth({ ok: false, error: "getExtensionEnv timed out after 5000ms" }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("getExtensionEnv timed out"), expect.anything()); + }); + + it("sandbox 从未上报就绪时,兜底超时后仍通知 SW 就绪,并记录明确的错误日志", () => { + vi.useFakeTimers(); + const { manager, preparationOffscreen } = buildManager(); + + // 直接触发兜底计时器的注册逻辑(initManager 本身依赖较重的服务构造,测试只关心兜底行为) + (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); + + expect(preparationOffscreen).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("no sandbox readiness signal received"), + expect.anything() + ); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + expect(preparationOffscreen).toHaveBeenCalledWith({ verified: false }); + }); + + it("真实握手先到达时,兜底超时不会重复通知 SW 就绪", () => { + vi.useFakeTimers(); + const { manager, preparationOffscreen } = buildManager(); + + (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); + manager.preparationSandbox(); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + }); + + it("兜底超时先触发后,迟到的真实握手会补发一次 verified 通知", () => { + vi.useFakeTimers(); + const { manager, preparationOffscreen } = buildManager(); + + (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); + vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + expect(preparationOffscreen).toHaveBeenLastCalledWith({ verified: false }); + + manager.preparationSandbox(); + expect(preparationOffscreen).toHaveBeenCalledTimes(2); + expect(preparationOffscreen).toHaveBeenLastCalledWith({ verified: true }); + + manager.preparationSandbox(); + expect(preparationOffscreen).toHaveBeenCalledTimes(2); + }); + + it("兜底初始化消息丢失后,迟到的真实握手会把后台脚本与语言各重放一次", async () => { + vi.useFakeTimers(); + const bus = new MockMessage(new EventEmitter()); + const offscreenServer = new Server("offscreen", bus); + const messageQueue = new LocalMessageQueue(); + const backgroundScript = { + uuid: "background-script", + name: "background-script", + type: SCRIPT_TYPE_BACKGROUND, + status: SCRIPT_STATUS_ENABLE, + } as ScriptRunResource; + const extMsgSender = { + connect: vi.fn(), + sendMessage: vi.fn(async (message: TMessage) => { + if (message.action === "serviceWorker/script/fetchInfo") { + return { code: 0, data: backgroundScript }; + } + if (message.action === "serviceWorker/script/getScriptRunResourceByUUID") { + return { code: 0, data: backgroundScript }; + } + return { code: 0 }; + }), + } as unknown as MessageSend; + let sandboxReady = false; + const attemptedActions: string[] = []; + const deliveredActions: string[] = []; + const windowMessage = { + connect: vi.fn(), + sendMessage: vi.fn((message: TMessage) => { + if (message.action) attemptedActions.push(message.action); + if (!sandboxReady) return new Promise(() => {}); + if (message.action) deliveredActions.push(message.action); + return Promise.resolve({ code: 0 }); + }), + } as unknown as WindowMessage; + const preparationOffscreen = vi.fn(() => { + messageQueue.publish("enableScripts", [{ uuid: backgroundScript.uuid, enable: true }]); + messageQueue.publish("setSandboxLanguage", "zh-CN"); + }); + const manager = new BackgroundEnvManagerBase( + extMsgSender, + windowMessage, + offscreenServer, + { preparationOffscreen } as unknown as ServiceWorkerClient, + messageQueue + ); + await manager.initManager(); + + vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); + await flushAsyncHandlers(); + + expect(attemptedActions).toEqual(expect.arrayContaining(["sandbox/enableScript", "sandbox/setSandboxLanguage"])); + expect(deliveredActions).toEqual([]); + + sandboxReady = true; + manager.preparationSandbox(); + await flushAsyncHandlers(); + + expect(deliveredActions.filter((action) => action === "sandbox/enableScript")).toHaveLength(1); + expect(deliveredActions.filter((action) => action === "sandbox/setSandboxLanguage")).toHaveLength(1); + + manager.preparationSandbox(); + await flushAsyncHandlers(); + expect(deliveredActions.filter((action) => action === "sandbox/enableScript")).toHaveLength(1); + expect(deliveredActions.filter((action) => action === "sandbox/setSandboxLanguage")).toHaveLength(1); + }); +}); diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index 90ed1cb5b..0cb1d78ca 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -1,28 +1,51 @@ import { forwardMessage, type Server } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; import { ScriptService } from "./script"; -import { type Logger } from "@App/app/repo/logger"; +import { type Logger as LoggerRecord } from "@App/app/repo/logger"; +import LoggerCore from "@App/app/logger/core"; import { type WindowMessage } from "@Packages/message/window_message"; import { type ServiceWorkerClient } from "../service_worker/client"; import { sendMessage } from "@Packages/message/client"; import GMApi from "./gm_api"; -import { MessageQueue } from "@Packages/message/message_queue"; +import { MessageQueue, type IMessageQueue } from "@Packages/message/message_queue"; import { VSCodeConnect } from "./vscode-connect"; import { HtmlExtractorService } from "./html_extractor"; import { makeBlobURL } from "@App/pkg/utils/utils"; +import { type SandboxChannelHealth } from "./client"; +import { startChromeOffscreenKeepAliveLoop } from "./keep_alive"; + +// 兜底超时:sandbox 若在此时长内从未发出就绪通知(iframe 加载失败/脚本异常等), +// 也不能让 SW 永久卡在等待 offscreen 就绪上,超时后仍放行,但打印明确的错误日志 +export const SANDBOX_READY_FALLBACK_MS = 15000; // offscreen环境的管理器 export class BackgroundEnvManagerBase { - private readonly messageQueue = new MessageQueue(); + private readonly handshakeLogger = LoggerCore.getInstance().logger({ component: "offscreen-sandbox-handshake" }); + + // fallback 只能表示父层不再无限等待,不能证明 sandbox 通道可用;真实握手需要独立记录, + // 这样 fallback 先发生时,迟到的 verified 握手仍可触发一次必要的状态重放。 + private fallbackReadyNotified = false; + private sandboxReadyVerified = false; constructor( private readonly extMsgSender: MessageSend, private readonly windowMessage: WindowMessage, private readonly offscreenServer: Server, - private readonly serviceWorker: ServiceWorkerClient + private readonly serviceWorker: ServiceWorkerClient, + // Chrome: offscreen 文档是独立进程,这里默认创建自己的 MessageQueue, + // 靠 chrome.runtime.sendMessage 广播与 SW 侧的 MessageQueue 互通,没有问题。 + // Firefox: EventPageOffscreenManager 与 SW 是同一个脚本/进程; + // chrome.runtime.sendMessage 广播不会送达"发送方自己所在的 frame" + // (https://developer.chrome.com/docs/extensions/reference/api/runtime#event-onMessage 明确写明 + // "except for the sender's frame"),所以若各自创建独立的 MessageQueue 实例, + // 广播永远到不了对方,enableScripts/deleteScripts/installScript/setSandboxLanguage 等 + // 全部失效——crontab 定时脚本正是靠 enableScripts 广播才会被 sandbox 端调度,这也是 + // "手动运行正常,但定时任务从不自动触发"的根本原因。因此 Firefox 侧必须把 SW 自己已有的 + // 同一个 MessageQueue 实例注入进来,让两边共用同一份 EventEmitter,而不是各自新建。 + private readonly messageQueue: IMessageQueue = new MessageQueue() ) {} - logger(data: Logger) { + logger(data: LoggerRecord) { // 发送日志消息 this.sendMessageToServiceWorker({ action: "logger", @@ -31,8 +54,44 @@ export class BackgroundEnvManagerBase { } preparationSandbox() { + // sandbox 主动通知自己已就绪(而非由父层猜测/轮询/ping 探测)。 + // Firefox 154+ 下 sandbox manifest 页面是跨源 iframe:contentDocument 为 null, + // contentWindow.location 不可读,父层没有别的办法探测其就绪状态,也不该去 ping sandbox—— + // 只有 sandbox 自己知道它什么时候真正就绪。sandbox 还会自行做一次通道自检, + // 结果通过 reportSandboxChannelHealth 单独上报(见下)。 + if (this.sandboxReadyVerified) return; + this.sandboxReadyVerified = true; + this.notifyOffscreenReady(true, "sandbox reported readiness"); + } + + // sandbox 自己主动做的通道连通性自检结果,记录到父层(offscreen 文档 / Firefox event page)的日志, + // 因为 sandbox 自身的控制台通常不便查看 + reportSandboxChannelHealth(health: SandboxChannelHealth) { + if (health.ok) { + this.handshakeLogger.debug(`sandbox communication verified (${health.roundTripMs}ms round trip)`); + } else { + this.handshakeLogger.error(`sandbox communication check failed: ${health.error}`); + } + } + + private notifyOffscreenReady(verified: boolean, reason: string) { + this.handshakeLogger.debug(`offscreen ready (${reason})`); // 通知初始化好环境了 - this.serviceWorker.preparationOffscreen(); + this.serviceWorker.preparationOffscreen({ verified }); + } + + // 兜底:sandbox 若因 iframe 加载失败/脚本异常等原因从未发出就绪通知,也不能让 SW 永久 + // 卡在等待 offscreen 就绪上 —— 超时后仍然放行,只是没有经过连通性验证 + private armReadyFallback() { + setTimeout(() => { + if (!this.sandboxReadyVerified && !this.fallbackReadyNotified) { + this.fallbackReadyNotified = true; + this.handshakeLogger.error( + `no sandbox readiness signal received within ${SANDBOX_READY_FALLBACK_MS}ms; proceeding without a verified sandbox channel` + ); + this.notifyOffscreenReady(false, "fallback timeout, sandbox never reported readiness"); + } + }, SANDBOX_READY_FALLBACK_MS); } async getExtensionEnv(data: { requireUAD: boolean }) { @@ -50,8 +109,11 @@ export class BackgroundEnvManagerBase { // 监听消息 this.offscreenServer.on("logger", this.logger.bind(this)); this.offscreenServer.on("preparationSandbox", this.preparationSandbox.bind(this)); + this.offscreenServer.on("reportSandboxChannelHealth", this.reportSandboxChannelHealth.bind(this)); this.offscreenServer.on("getExtensionEnv", this.getExtensionEnv.bind(this)); this.offscreenServer.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this)); + this.offscreenServer.on("keepAlive", startChromeOffscreenKeepAliveLoop()); + this.armReadyFallback(); const script = new ScriptService( this.offscreenServer.group("script"), this.extMsgSender, diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index 93bfee267..7e81aea8c 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -8,10 +8,22 @@ export function preparationSandbox(windowMessage: WindowMessage) { return sendMessage(windowMessage, "offscreen/preparationSandbox"); } +// sandbox 自身对通道做的一次连通性自检结果(只有 sandbox 自己知道它何时就绪、何时做完这次自检, +// 因此由 sandbox 主动上报,而不是由父层去 ping sandbox) +export type SandboxChannelHealth = { ok: true; roundTripMs: number } | { ok: false; error: string }; + +export function reportSandboxChannelHealth(windowMessage: WindowMessage, health: SandboxChannelHealth) { + return sendMessage(windowMessage, "offscreen/reportSandboxChannelHealth", health); +} + export function getExtensionEnv(windowMessage: WindowMessage) { return sendMessage(windowMessage, "offscreen/getExtensionEnv", { requireUAD: true }); } +export function keepAlive(windowMessage: WindowMessage, val: boolean) { + return sendMessage(windowMessage, "offscreen/keepAlive", val); +} + // 代理发送消息到ServiceWorker export function sendMessageToServiceWorker(windowMessage: WindowMessage, action: string, data?: any) { return sendMessage(windowMessage, "offscreen/sendMessageToServiceWorker", { action, data }); diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts new file mode 100644 index 000000000..c133baf71 --- /dev/null +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -0,0 +1,339 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import chromeMock from "@Packages/chrome-extension-mock"; +import { Server } from "@Packages/message/server"; +import { MessageQueue } from "@Packages/message/message_queue"; +import type LoggerCoreType from "../../logger/core"; +import { EventPageOffscreenManager, InProcessMessage } from "./event_page_manager"; + +initTestEnv(); + +// keep_alive.ts 在模块顶层把 isFirefox() 固化为 boolFirefox 常量;测试环境不是 Firefox, +// 必须 mock isFirefox 并配合 vi.resetModules() 重新导入,Firefox 保活分支才会生效。 +const { isFirefoxMock } = vi.hoisted(() => ({ + isFirefoxMock: vi.fn(() => false), +})); + +vi.mock("@App/pkg/utils/utils", async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, isFirefox: isFirefoxMock }; +}); + +const RealImage = globalThis.Image; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.stubGlobal("scheduler", undefined); + vi.restoreAllMocks(); + isFirefoxMock.mockReturnValue(false); + vi.stubGlobal("Image", RealImage); + chromeMock.init(); + for (const node of Array.from(document.documentElement.querySelectorAll("iframe,img"))) { + node.remove(); + } +}); + +const loadKeepAliveEnabledManager = async () => { + vi.stubGlobal("scheduler", { + postTask: vi.fn(), + }); + isFirefoxMock.mockReturnValue(true); + // Firefox 分支的探测 不再挂载到 DOM(仅靠变量引用防 GC),改用可追踪的 Image 替身观察探测请求 + const probeImages: Array<{ src: string; onload: unknown; onerror: unknown }> = []; + vi.stubGlobal( + "Image", + class { + onload: unknown = null; + onerror: unknown = null; + src = ""; + constructor() { + probeImages.push(this); + } + } + ); + vi.resetModules(); + const loggerModule = await import("../../logger/core.js"); + const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType; + const logger = new LoggerCore({ + level: "trace", + consoleLevel: "trace", + writer: new loggerModule.EmptyWriter(), + labels: { env: "test" }, + }); + logger.logger().debug("test start"); + return { ...(await import("./event_page_manager.js")), ...(await import("./keep_alive.js")), probeImages }; +}; + +const loadChromeKeepAlive = async () => { + isFirefoxMock.mockReturnValue(false); + vi.resetModules(); + return await import("./keep_alive.js"); +}; + +// 单测重点:Firefox MV3 下事件页(EventPageOffscreenManager)与 service worker 是同一个脚本/进程, +// offscreen -> SW 方向不能走 chrome.runtime.sendMessage(自己发给自己会报 "Could not establish +// connection. Receiving end does not exist.",进而在 packages/message/client.ts 的 sendMessage() +// 里因 res 为 undefined 触发 "can't access property data" 的错误日志)。 +// 这里验证:只要把一个 InProcessMessage 桥接实例,同时注册为 "serviceWorker" Server 的 receiver、 +// 并传给 EventPageOffscreenManager 作为 extMsgSender,offscreen -> SW 的调用就能在进程内真正 +// 送达并拿到响应,而不是得到 undefined。 +describe("EventPageOffscreenManager <-> serviceWorker 进程内桥接", () => { + it("offscreen 发起的 getExtensionEnv 通过进程内桥接真正到达 serviceWorker 端处理器并拿到响应", async () => { + const bridge = new InProcessMessage(); + const serviceWorkerHandler = vi.fn().mockResolvedValue({ inIncognitoContext: false }); + const serviceWorkerServer = new Server("serviceWorker", bridge); + serviceWorkerServer.on("getExtensionEnv", serviceWorkerHandler); + + const manager = new EventPageOffscreenManager(bridge, new MessageQueue()); + + const result = await manager.getExtensionEnv({ requireUAD: false }); + + expect(serviceWorkerHandler).toHaveBeenCalledTimes(1); + expect(serviceWorkerHandler).toHaveBeenCalledWith({ requireUAD: false }, expect.anything()); + expect(result).toEqual({ inIncognitoContext: false }); + }); + + it("offscreen 发起的 preparationOffscreen 通知也通过进程内桥接真正到达 serviceWorker 端处理器", async () => { + const bridge = new InProcessMessage(); + const serviceWorkerHandler = vi.fn().mockResolvedValue(undefined); + const serviceWorkerServer = new Server("serviceWorker", bridge); + serviceWorkerServer.on("preparationOffscreen", serviceWorkerHandler); + + const manager = new EventPageOffscreenManager(bridge, new MessageQueue()); + + // 触发 sandbox 就绪通知:会走 notifyOffscreenReady -> this.serviceWorker.preparationOffscreen() + manager.preparationSandbox(); + + // preparationOffscreen() 内部走 sendMessage,是异步的;等待其对应的微任务/宏任务跑完 + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(serviceWorkerHandler).toHaveBeenCalledTimes(1); + }); +}); + +// 单测重点:SW 与 offscreen 必须共用同一个 MessageQueue 实例,而不是各自新建一个。 +// MessageQueue.publish() 的跨上下文广播只走 chrome.runtime.sendMessage,而该 API 明确不会把消息 +// 送回发送方自己所在的 frame;Firefox 下 SW 和 offscreen(EventPageOffscreenManager)是同一个 +// 脚本/进程/frame,所以如果两边各自 `new MessageQueue()`,SW 侧 mq.publish("enableScripts", ...) +// 永远到不了 offscreen 侧的 ScriptService 订阅——crontab 定时脚本正是靠这条广播才会被 sandbox +// 端调度,这也是"手动运行正常,但 crontab 从不自动触发"的根本原因。 +// 修复方式:把 SW 侧已有的 MessageQueue 实例注入进 EventPageOffscreenManager,两边共用同一个 +// EventEmitter,这样 publish() 内部的本地 this.EE.emit() 就足以让两边互通,不再依赖 +// chrome.runtime.sendMessage 广播。 +describe("EventPageOffscreenManager 与 SW 共用 MessageQueue", () => { + it("构造时传入的 MessageQueue 实例被原样保留,而不是内部另建一个新的", () => { + const bridge = new InProcessMessage(); + const sharedQueue = new MessageQueue(); + + const manager = new EventPageOffscreenManager(bridge, sharedQueue); + + expect((manager as unknown as { messageQueue: MessageQueue }).messageQueue).toBe(sharedQueue); + }); + + it("共用同一个 MessageQueue 时,SW 侧 publish 的 enableScripts 能被 offscreen 侧的订阅收到", () => { + const sharedQueue = new MessageQueue(); + + // 模拟 offscreen 侧(ScriptService.init() 中就是这样订阅 "enableScripts" 的) + const received: unknown[] = []; + sharedQueue.subscribe("enableScripts", (data) => received.push(data)); + + // 模拟 SW 侧(RuntimeService 的 preparationOffscreen 订阅里就是这样发布的) + // 注:真实 chrome.runtime.sendMessage 在 Firefox 下不会回送给发送方自己所在的 frame, + // 但测试用的 chrome-extension-mock 没有模拟"排除发送方所在 frame"这一语义,所以在共用 + // 同一实例时,本地 this.EE.emit() 和 mock 的 chrome.runtime.onMessage 回环各触发一次, + // 收到 2 条;这里只断言"确实收到了",不依赖具体次数。 + sharedQueue.publish("enableScripts", [{ uuid: "script-uuid", enable: true }]); + + expect(received.length).toBeGreaterThan(0); + expect(received[0]).toEqual([{ uuid: "script-uuid", enable: true }]); + }); +}); + +describe("EventPageOffscreenManager Firefox event page 保活权限门控", () => { + it("运行时配置关闭时即使权限存在也不注册 listener 或发起探测", async () => { + chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); + const { startFirefoxEventPageKeepAliveLoop, probeImages } = await loadKeepAliveEnabledManager(); + + startFirefoxEventPageKeepAliveLoop()(false); + await Promise.resolve(); + + expect((chrome.webRequest.onBeforeRequest as any).listeners).toHaveLength(0); + expect(probeImages).toHaveLength(0); + }); + + it("权限未授予时不注册 blocking listener,也不发起首拍探测", async () => { + chromeMock.permissions.__setGrantedPermissions([]); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + startFirefoxEventPageKeepAliveLoop, + probeImages, + } = await loadKeepAliveEnabledManager(); + + startFirefoxEventPageKeepAliveLoop()(true); + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + await Promise.resolve(); + + expect((chrome.webRequest.onBeforeRequest as any).listeners).toHaveLength(0); + expect(probeImages).toHaveLength(0); + }); + + it("权限已授予时注册 blocking listener 并发起首拍探测", async () => { + chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + startFirefoxEventPageKeepAliveLoop, + probeImages, + } = await loadKeepAliveEnabledManager(); + + startFirefoxEventPageKeepAliveLoop()(true); + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + await Promise.resolve(); + + expect((chrome.webRequest.onBeforeRequest as any).listeners).toHaveLength(1); + expect((chrome.webRequest.onBeforeRequest as any).listeners[0].extraInfoSpec).toEqual(["blocking"]); + expect(probeImages).toHaveLength(1); + expect(probeImages[0].src).toContain("__network_delay="); + }); + + it("运行中收到 webRequestBlocking 授权后启动循环,重复触发不会重复注册", async () => { + chromeMock.permissions.__setGrantedPermissions([]); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + startFirefoxEventPageKeepAliveLoop, + probeImages, + } = await loadKeepAliveEnabledManager(); + + startFirefoxEventPageKeepAliveLoop()(true); + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + await Promise.resolve(); + + chrome.permissions.request({ permissions: ["webRequestBlocking"] }); + chrome.permissions.request({ permissions: ["webRequestBlocking"] }); + + expect((chrome.webRequest.onBeforeRequest as any).listeners).toHaveLength(1); + expect(probeImages).toHaveLength(1); + }); + + it("移除 webRequestBlocking 后立即停止 blocking 探测,重新授权后恢复", async () => { + chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + startFirefoxEventPageKeepAliveLoop, + probeImages, + } = await loadKeepAliveEnabledManager(); + + startFirefoxEventPageKeepAliveLoop()(true); + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + await Promise.resolve(); + + const listener = (chrome.webRequest.onBeforeRequest as any).listeners[0].callback; + // 与 keep_alive.ts 相同的派生规则:探测域名来自扩展自身的 runtime.getURL + const extensionId = new URL(chrome.runtime.getURL("/")).hostname; + const probeRequest = { + url: `https://--extensions-${extensionId}.invalid?__network_delay=10000`, + }; + + expect(listener(probeRequest)).toBeInstanceOf(Promise); + + chrome.permissions.remove({ permissions: ["webRequestBlocking"] }); + + expect(listener(probeRequest)).toEqual({}); + + chrome.permissions.request({ permissions: ["webRequestBlocking"] }); + + expect(listener(probeRequest)).toBeInstanceOf(Promise); + // 首拍探测 + 重新授权后的恢复探测 + expect(probeImages).toHaveLength(2); + }); + + it("构造同步回合内先注册 permissions.onAdded,再等待 contains 结果", async () => { + chromeMock.permissions.__setGrantedPermissions([]); + const contains = vi.spyOn(chrome.permissions, "contains").mockReturnValue(new Promise(() => {}) as never); + const addListener = vi.spyOn(chrome.permissions.onAdded, "addListener"); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + startFirefoxEventPageKeepAliveLoop, + } = await loadKeepAliveEnabledManager(); + + startFirefoxEventPageKeepAliveLoop(); + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + + expect(addListener).toHaveBeenCalledTimes(1); + expect(contains).toHaveBeenCalledTimes(1); + expect(addListener.mock.invocationCallOrder[0]).toBeLessThan(contains.mock.invocationCallOrder[0]); + }); +}); + +describe("Chrome offscreen document 保活探测", () => { + it("通过 runtime port 每 20 秒发送一次保活消息", async () => { + const scheduledTasks: Array<() => void> = []; + const postTask = vi.fn((task: () => void) => { + scheduledTasks.push(task); + return Promise.resolve(); + }); + vi.stubGlobal("scheduler", { postTask }); + const port = { + name: "scriptcat-keep-alive", + postMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: { addListener: vi.fn() }, + } as unknown as chrome.runtime.Port; + const connect = vi.spyOn(chrome.runtime, "connect").mockReturnValue(port); + const { startChromeOffscreenKeepAliveLoop } = await loadChromeKeepAlive(); + + const setKeepAliveEnabled = startChromeOffscreenKeepAliveLoop(); + setKeepAliveEnabled(true); + + expect(connect).toHaveBeenCalledWith({ name: "scriptcat-keep-alive" }); + expect(port.postMessage).toHaveBeenCalledWith({ type: "keep-alive" }); + expect(postTask).toHaveBeenCalledWith(expect.any(Function), { + priority: "user-visible", + delay: 20_000, + }); + + scheduledTasks.shift()?.(); + expect(port.postMessage).toHaveBeenCalledTimes(2); + expect(postTask).toHaveBeenCalledTimes(2); + + setKeepAliveEnabled(false); + expect(port.disconnect).toHaveBeenCalledTimes(1); + scheduledTasks.shift()?.(); + expect(port.postMessage).toHaveBeenCalledTimes(2); + expect(postTask).toHaveBeenCalledTimes(2); + }); + + it("service worker 重启并重新发现既有 offscreen document 后会同步当前保活开关", async () => { + const subscriptions = new Map void>(); + const messageQueue = { + subscribe: vi.fn((topic: string, handler: (data: unknown) => void) => { + subscriptions.set(topic, handler); + return () => subscriptions.delete(topic); + }), + }; + const systemConfig = { + watch: vi.fn((_key: string, handler: (value: boolean, prev: boolean | undefined) => void) => { + handler(true, undefined); + return vi.fn(); + }), + }; + const offscreenSend = { + init: vi.fn().mockResolvedValue(undefined), + connect: vi.fn(), + sendMessage: vi.fn(() => new Promise(() => {})), + }; + const { hookServiceWorkerKeepAliveLoop } = await loadChromeKeepAlive(); + + hookServiceWorkerKeepAliveLoop(systemConfig as never, messageQueue as never, offscreenSend as never); + subscriptions.get("offscreenDocumentReady")?.({}); + await Promise.resolve(); + await Promise.resolve(); + + expect(offscreenSend.sendMessage).toHaveBeenCalledWith({ action: "offscreen/keepAlive", data: true }); + }); +}); diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index eff5f8dcb..ac019c1ea 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -9,6 +9,7 @@ import type { } from "@Packages/message/types"; import { WindowMessage } from "@Packages/message/window_message"; import EventEmitter from "eventemitter3"; +import { type IMessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerClient } from "../service_worker/client"; import { BackgroundEnvManagerBase } from "./base"; @@ -48,7 +49,11 @@ class InProcessMessageConnect implements MessageConnect { } } -class InProcessMessage implements Message, MessageSend { +// Firefox MV3 的 event page 同时承担 service worker 与 offscreen 两个角色,二者处在同一 +// JavaScript 上下文。runtime messaging 不会把消息回送给发送者所在的 frame,因此这里用 +// EventEmitter 实现进程内的 Message / MessageSend。service_worker.ts 用它承载 offscreen → SW; +// EventPageOffscreenManager 内部的实例承载 SW → offscreen。 +export class InProcessMessage implements Message, MessageSend { private events = new EventEmitter(); connect(data: TMessage): Promise { @@ -83,28 +88,37 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen private readonly message: InProcessMessage; private initialized = false; - constructor(extMsgSender: MessageSend) { + constructor( + extMsgSender: MessageSend, + // Firefox 的 SW 与 offscreen 共处同一 frame,runtime.sendMessage 广播不会回送给发送者; + // 因此必须注入 SW 已有的队列,让 publish() 的本地 EventEmitter 完成分发。 + // Chrome 的独立 offscreen 文档仍由 BackgroundEnvManagerBase 使用默认队列。 + messageQueue: IMessageQueue + ) { if (typeof document !== "object" || !document?.documentElement) { throw new Error("EventPageOffscreenManager requires a DOM-capable Firefox MV3 Event Page."); } const sandbox = document.createElement("iframe"); - sandbox.src = chrome.runtime.getURL("src/sandbox.html"); + sandbox.src = chrome.runtime.getURL("/src/sandbox.html"); sandbox.style.display = "none"; document.documentElement.appendChild(sandbox); - const target = sandbox.contentWindow; - if (!target) { - throw new Error("EventPageOffscreenManager failed to create sandbox iframe."); - } - const message = new InProcessMessage(); - const windowMessage = new WindowMessage(window, target); + // iframe 创建后会从 about:blank 导航到跨源 sandbox 页面。惰性读取 contentWindow,避免 + // 在导航前固定目标引用,并在 iframe 被移除时给出明确错误。 + const windowMessage = new WindowMessage(window, () => { + const win = sandbox.contentWindow; + if (!win) { + throw new Error("EventPageOffscreenManager: sandbox iframe has no contentWindow (removed from DOM?)."); + } + return win; + }); const offscreenServer = new Server("offscreen", [message, windowMessage]); const serviceWorker = new ServiceWorkerClient(extMsgSender); - super(extMsgSender, windowMessage, offscreenServer, serviceWorker); + super(extMsgSender, windowMessage, offscreenServer, serviceWorker, messageQueue); this.message = message; } diff --git a/src/app/service/offscreen/index.ts b/src/app/service/offscreen/index.ts index 870e8e27e..94e21f9ff 100644 --- a/src/app/service/offscreen/index.ts +++ b/src/app/service/offscreen/index.ts @@ -7,7 +7,20 @@ import { BackgroundEnvManagerBase } from "./base"; // offscreen环境的管理器 export class OffscreenManager extends BackgroundEnvManagerBase { constructor(extMsgSender: MessageSend) { - const windowMessage = new WindowMessage(window, sandbox, true); + // `sandbox` 是 offscreen.html 中具名 iframe 的全局绑定(见 src/types/main.d.ts)。 + // 传入函数而非直接传值,与 Firefox 的 EventPageOffscreenManager 保持一致, + // 避免早于导航完成时缓存的 WindowProxy 引用带来的潜在身份不一致问题。 + // `sandbox` 的类型声明断言为非 null,但这只是编译期断言,运行时仍需校验。 + const windowMessage = new WindowMessage( + window, + () => { + if (!sandbox) { + throw new Error("OffscreenManager: named sandbox iframe is not available."); + } + return sandbox; + }, + true + ); const windowServer = new Server("offscreen", windowMessage); const serviceWorker = new ServiceWorkerClient(extMsgSender); super(extMsgSender, windowMessage, windowServer, serviceWorker); diff --git a/src/app/service/offscreen/keep_alive.ts b/src/app/service/offscreen/keep_alive.ts new file mode 100644 index 000000000..2b819bf35 --- /dev/null +++ b/src/app/service/offscreen/keep_alive.ts @@ -0,0 +1,345 @@ +import { type SystemConfig } from "@App/pkg/config/config"; +import { isFirefox } from "@App/pkg/utils/utils"; +import { sendMessage } from "@Packages/message/client"; +import type { IOffscreenSend } from "@Packages/message/types"; +import type { IMessageQueue } from "@Packages/message/message_queue"; + +// Firefox 的 blocking listener 依赖 Scheduler API;Chrome 使用 runtime port 心跳。 +const nativeScheduler = + typeof scheduler !== "undefined" && typeof scheduler?.postTask === "function" ? scheduler : null; + +const deferredResponse = (o: T, delayMs: number) => + new Promise((resolve) => { + if (!nativeScheduler) { + resolve(o); + return; + } + + // user-visible 优先级用于尽量减少后台页面降频;它不保证永久存活。 + nativeScheduler.postTask( + () => { + resolve(o); + }, + { priority: "user-visible", delay: delayMs } + ); + }); + +// 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 +const getKeepAliveProbeUrl = () => { + const extensionId = new URL(chrome.runtime.getURL("/")).hostname; + return `https://--extensions-${extensionId}.invalid`; +}; + +const KEEP_ALIVE_PORT_NAME = "scriptcat-keep-alive"; +const KEEP_ALIVE_HEARTBEAT_INTERVAL_MS = 20_000; +const KEEP_ALIVE_HEARTBEAT_MESSAGE = { type: "keep-alive" } as const; + +/** + * Firefox MV3 event page 保活实验(默认关闭)。 + * + * 背景:Firefox 没有 Chrome 的 offscreen document;event page 被挂起时,sandbox iframe 及其 + * 内存态定时任务会一并销毁,当前代码不会在恢复后重建这些 `@crontab` 调度。 + * + * 机制:使用 `webRequestBlocking` 暂停一个扩展自身发起的探测请求,并用 + * `scheduler.postTask()` 延迟返回 blocking 响应;`` 在请求结束后继续下一轮。 + * 未完成网络请求可能阻止 event page 被判定为空闲。 + * + * 启用条件: + * - 当前环境是 Firefox; + * - 浏览器提供 `scheduler.postTask`; + * - `keep_ext_background_alive` 运行时配置已开启; + * - 用户通过安装提示或设置页授予 `webRequestBlocking`; + * + * 这不是 Firefox 保证的生命周期机制。Firefox 或 Scheduler 条件不满足时函数为空操作;运行时 + * 配置或权限不满足时不启动探测。探测未被实际延迟时,`onKeepAliveProbeSettled` 会停止循环, + * 避免快速重试。 + */ + +const boolFirefox = isFirefox(); + +// 期望每个 blocking request 保持未完成的时间。 +const KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS = 10_000; +// 实际往返过短说明 listener 未阻塞请求(通常是权限缺失或请求提前失败); +// 此时停止后续探测,避免失败请求形成紧密循环。 +const KEEP_ALIVE_MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; +const KEEP_ALIVE_MAX_PROBE_DELAY_MS = 120_000; + +const createKeepAliveProbeLoop = (keepAliveProbeUrl: string) => { + let enabled = false; + let probeStartedAt: number; + + // 注意:不要让 image 在未 settled 前 GC 掉 + let image: HTMLImageElement | null = null; + + const onFirefoxProbeSettled = (ev: Event | string) => { + if (!(ev instanceof Event)) return; + ev.preventDefault(); + ev.stopPropagation(); + ev.stopImmediatePropagation(); + if (!image || ev.currentTarget !== image) return; + + // 上一轮的 image 可以被 GC 了 + image.onload = image.onerror = null; + image = null; + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免忙循环。 + if (!enabled || Date.now() - probeStartedAt < KEEP_ALIVE_MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + // 下一轮 + sendKeepAliveProbe(); + }; + + // 发起一次探测请求,作为心跳循环的一拍 + const sendKeepAliveProbe = () => { + image = new Image(1, 1); + probeStartedAt = Date.now(); + + // onload / onerror 在调用前 image 不会被 GC + image.onload = onFirefoxProbeSettled; + image.onerror = onFirefoxProbeSettled; + image.src = `${keepAliveProbeUrl}?__network_delay=${KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; + }; + + return { + start() { + if (enabled) return; + enabled = true; + sendKeepAliveProbe(); + }, + stop() { + enabled = false; + }, + isEnabled() { + return enabled; + }, + }; +}; + +const TRANSPARENT_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +export const startFirefoxEventPageKeepAliveLoop = + boolFirefox && nativeScheduler + ? () => { + let running = false; + let configEnabled = false; + let permissionGranted = false; + // 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 + const keepAliveProbeUrl = getKeepAliveProbeUrl(); + const keepAlive = createKeepAliveProbeLoop(keepAliveProbeUrl); + const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; + + const dataUri = `data:image/gif;base64,${TRANSPARENT_GIF_BASE64}`; + + const syncLoop = () => { + if (!configEnabled || !permissionGranted) { + keepAlive.stop(); + return; + } + if (running) { + keepAlive.start(); + return; + } + running = true; + + // blocking listener 只处理带延迟标记的探测请求,并在 delayMs 后放行。 + chrome.webRequest.onBeforeRequest.addListener( + (details) => { + const lastError = chrome.runtime.lastError; + + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRequest:", lastError); + } + + if (!keepAlive.isEnabled() || !details.url.includes(keepAliveProbeUrl)) { + return {}; + } + const url = new URL(details.url); + + // 只延迟明确带有延迟标记的请求,避免误伤同源下的其它请求 + if (!url.searchParams.has("__network_delay")) { + return {}; + } + + const requestedDelay = Number(url.searchParams.get("__network_delay")); + + const delayMs = Number.isFinite(requestedDelay) + ? Math.max(0, Math.min(requestedDelay, KEEP_ALIVE_MAX_PROBE_DELAY_MS)) + : KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS; + + const response = { redirectUrl: dataUri }; + return deferredResponse(response, delayMs); + }, + { + urls: [`${keepAliveProbeOrigin}/*`], + types: ["xmlhttprequest", "image"], + }, + ["blocking"] + ); + + keepAlive.start(); + }; + + // 必须在事件页首个同步回合注册;权限移除时立即关闭探测并保留监听器以便重新授权。 + chrome.permissions.onAdded.addListener((permissions) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.permissions.onAdded:", lastError); + return; + } + if (permissions.permissions?.includes("webRequestBlocking")) { + permissionGranted = true; + syncLoop(); + } + }); + + chrome.permissions.onRemoved.addListener((permissions) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.permissions.onRemoved:", lastError); + return; + } + if (permissions.permissions?.includes("webRequestBlocking")) { + permissionGranted = false; + syncLoop(); + } + }); + + void chrome.permissions.contains({ permissions: ["webRequestBlocking"] }).then((granted) => { + permissionGranted = granted; + syncLoop(); + }); + + return (enabled: boolean) => { + configEnabled = enabled; + syncLoop(); + }; + } + : () => (_enabled: boolean) => {}; + +export const hookFirefoxEventPageKeepAliveLoop = (systemConfig: SystemConfig) => { + const setKeepAliveEnabled = startFirefoxEventPageKeepAliveLoop(); + systemConfig.watch("keep_ext_background_alive", (value) => setKeepAliveEnabled(value)); +}; + +/** + * Chrome MV3 service worker 侧接收 offscreen document 的 runtime port 心跳。 + * + * Chrome MV3 的 offscreen document 网络事件不会经过扩展 service worker 的 `fetch` 监听器, + * Chrome 文档说明,长连接上的消息会延长 service worker 生命周期;仅保持 port 打开不够, + * 因此 offscreen document 每 20 秒发送一条消息。 + */ +export const startChromeServiceWorkerKeepAliveLoop = !boolFirefox + ? () => { + let keepAlivePort: chrome.runtime.Port | null = null; + + chrome.runtime.onConnect.addListener((port) => { + if (port.name !== KEEP_ALIVE_PORT_NAME) return; + keepAlivePort?.disconnect(); + keepAlivePort = port; + port.onMessage.addListener((message) => { + if (message?.type !== KEEP_ALIVE_HEARTBEAT_MESSAGE.type) return; + }); + port.onDisconnect.addListener(() => { + if (keepAlivePort === port) keepAlivePort = null; + }); + }); + + return (val: boolean) => { + if (val) return; + keepAlivePort?.disconnect(); + keepAlivePort = null; + }; + } + : () => (_val: boolean) => {}; + +export const hookServiceWorkerKeepAliveLoop = ( + systemConfig: SystemConfig, + messageQueue: IMessageQueue, + offscreenSend: IOffscreenSend +) => { + const setKeepAliveEnabled = startChromeServiceWorkerKeepAliveLoop(); + let offscreenReady = false; + let enabled = false; + + const informOffscreen = () => { + if (!offscreenReady) return; + void sendMessage(offscreenSend, "offscreen/keepAlive", enabled).catch((error) => { + console.error("Failed to update offscreen keep-alive state:", error); + }); + }; + + systemConfig.watch("keep_ext_background_alive", (value, prev) => { + enabled = value; + setKeepAliveEnabled(value); + if (value !== prev) informOffscreen(); + }); + messageQueue.subscribe("preparationOffscreen", (data: { verified: boolean }) => { + if (!data.verified) return; + offscreenReady = true; + informOffscreen(); + }); + // Chrome 的 offscreen document 生命周期可跨 service worker 重启;重新发现既有文档时, + // sandbox 不会重新加载并再次握手,因此需要独立同步当前保活配置。 + messageQueue.subscribe("offscreenDocumentReady", () => { + offscreenReady = true; + informOffscreen(); + }); +}; + +/** + * Chrome MV3 offscreen document 通过 runtime port 发送保活心跳。 + * + * offscreen document 的生命周期独立于 service worker,因此它可以作为心跳发送端;service + * worker 收到每条消息后会刷新 MV3 的活动窗口。 + * + * 返回一个 setter:配置项初次异步加载完成或用户在设置页切换开关时都会调用它, + * 从关闭切到开启时立即发起新一轮探测循环,切回关闭时让 `onKeepAliveProbeSettled` 中的 + * `enabled` 检查在当前这次探测结束后自然停止循环。 + */ +export const startChromeOffscreenKeepAliveLoop = + !boolFirefox && nativeScheduler + ? () => { + let enabled = false; + let port: chrome.runtime.Port | null = null; + let heartbeatScheduled = false; + + const sendHeartbeat = () => { + if (!enabled) return; + if (!port) { + port = chrome.runtime.connect({ name: KEEP_ALIVE_PORT_NAME }); + const connectedPort = port; + port.onDisconnect.addListener(() => { + if (port === connectedPort) port = null; + }); + } + try { + port.postMessage(KEEP_ALIVE_HEARTBEAT_MESSAGE); + } catch { + port = null; + } + }; + + const scheduleHeartbeat = () => { + if (!enabled || heartbeatScheduled || !nativeScheduler) return; + heartbeatScheduled = true; + nativeScheduler.postTask( + () => { + heartbeatScheduled = false; + if (!enabled) return; + sendHeartbeat(); + scheduleHeartbeat(); + }, + { priority: "user-visible", delay: KEEP_ALIVE_HEARTBEAT_INTERVAL_MS } + ); + }; + + return (val: boolean) => { + enabled = val; + if (enabled) { + sendHeartbeat(); + scheduleHeartbeat(); + return; + } + port?.disconnect(); + port = null; + }; + } + : () => (_val: boolean) => {}; diff --git a/src/app/service/sandbox/index.test.ts b/src/app/service/sandbox/index.test.ts new file mode 100644 index 000000000..dfd69a673 --- /dev/null +++ b/src/app/service/sandbox/index.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { MockMessage } from "@Packages/message/mock_message"; +import { Server } from "@Packages/message/server"; +import EventEmitter from "eventemitter3"; +import type { WindowMessage } from "@Packages/message/window_message"; +import { SandboxManager } from "./index"; + +initTestEnv(); + +// 单测重点:sandbox 主动、立即上报就绪(不等待任何往返请求),并在此后非阻塞地对自己发起的 +// getExtensionEnv 请求做一次连通性自检,把结果(成功/超时)上报给父层记录 —— 父层不会、也不需要 +// 反过来 ping sandbox,因为只有 sandbox 自己知道它什么时候真正就绪 +describe("SandboxManager 就绪与通道自检上报", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("初始化时立即上报就绪,不等待 getExtensionEnv 完成", () => { + const bus = new MockMessage(new EventEmitter()); + const offscreenServer = new Server("offscreen", bus); + const preparationSandbox = vi.fn(); + offscreenServer.on("preparationSandbox", preparationSandbox); + // 故意不注册 getExtensionEnv 处理器:模拟该请求一直挂起,不应阻塞就绪上报 + offscreenServer.on("reportSandboxChannelHealth", vi.fn()); + + const manager = new SandboxManager(bus as unknown as WindowMessage); + manager.initManager(); + + expect(preparationSandbox).toHaveBeenCalledTimes(1); + }); + + it("getExtensionEnv 及时响应时,上报通道自检成功", async () => { + const bus = new MockMessage(new EventEmitter()); + const offscreenServer = new Server("offscreen", bus); + offscreenServer.on("preparationSandbox", vi.fn()); + offscreenServer.on("getExtensionEnv", () => ({ inIncognitoContext: false })); + const reportHealth = vi.fn(); + offscreenServer.on("reportSandboxChannelHealth", reportHealth); + + const manager = new SandboxManager(bus as unknown as WindowMessage); + manager.initManager(); + + // 让 getExtensionEnv 的响应和后续的 .then() 链有机会跑完(多层 Promise.race/finally/then) + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(reportHealth).toHaveBeenCalledWith( + expect.objectContaining({ ok: true, roundTripMs: expect.any(Number) }), + expect.anything() + ); + }); + + it("getExtensionEnv 超时未响应时,上报通道自检失败(附带具体原因)", async () => { + vi.useFakeTimers(); + const bus = new MockMessage(new EventEmitter()); + const offscreenServer = new Server("offscreen", bus); + offscreenServer.on("preparationSandbox", vi.fn()); + // 注册一个永不 resolve 的处理器,模拟父层收到请求但从未响应(通道单向不可用), + // 而不是不注册该 action —— 后者会被 Server 当作"没有这个 API"立即报错,无法模拟真正的挂起 + offscreenServer.on("getExtensionEnv", () => new Promise(() => {})); + const reportHealth = vi.fn(); + offscreenServer.on("reportSandboxChannelHealth", reportHealth); + + const manager = new SandboxManager(bus as unknown as WindowMessage); + manager.initManager(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(reportHealth).toHaveBeenCalledWith( + expect.objectContaining({ ok: false, error: expect.stringContaining("timed out") }), + expect.anything() + ); + }); +}); diff --git a/src/app/service/sandbox/index.ts b/src/app/service/sandbox/index.ts index 7c2bfd9e3..6c6c56724 100644 --- a/src/app/service/sandbox/index.ts +++ b/src/app/service/sandbox/index.ts @@ -1,8 +1,12 @@ import { Server } from "@Packages/message/server"; import { type WindowMessage } from "@Packages/message/window_message"; -import { getExtensionEnv, preparationSandbox } from "../offscreen/client"; +import { getExtensionEnv, preparationSandbox, reportSandboxChannelHealth } from "../offscreen/client"; +import { withTimeout } from "@App/pkg/utils/with_timeout"; import { Runtime } from "./runtime"; +// 通道自检的超时时间:只影响自检结果的上报,不阻塞 sandbox 自身的初始化或就绪通知 +const SANDBOX_CHANNEL_CHECK_TIMEOUT_MS = 5000; + // sandbox环境的管理器 export class SandboxManager { api: Server; @@ -15,7 +19,31 @@ export class SandboxManager { const extensionEnvAsync = getExtensionEnv(this.windowMessage); const runtime = new Runtime(this.windowMessage, this.api, extensionEnvAsync); runtime.init(); - // 通知初始化好环境了 + // 通知初始化好环境了:由 sandbox 自己主动上报,因为只有 sandbox 自己知道它何时就绪, + // 父层(offscreen 文档 / Firefox event page)不应该、也没办法去猜测或轮询这一点。 preparationSandbox(this.windowMessage); + // 非阻塞地对刚发起的 getExtensionEnv 请求做一次连通性自检并上报结果——不额外发起新的往返 + // 请求,只是给这次已经在途的请求包一层超时观察;结果交由父层记录/打印,因为 sandbox 自身 + // 的控制台通常不便查看。 + this.reportChannelHealth(extensionEnvAsync); + } + + private reportChannelHealth(extensionEnvAsync: Promise) { + const start = Date.now(); + withTimeout( + extensionEnvAsync, + SANDBOX_CHANNEL_CHECK_TIMEOUT_MS, + () => new Error(`getExtensionEnv timed out after ${SANDBOX_CHANNEL_CHECK_TIMEOUT_MS}ms`) + ) + .then(() => reportSandboxChannelHealth(this.windowMessage, { ok: true, roundTripMs: Date.now() - start })) + .catch((e) => + reportSandboxChannelHealth(this.windowMessage, { + ok: false, + error: e instanceof Error ? e.message : String(e), + }) + ) + .catch(() => { + // 若连上报本身都发不出去(通道确实不通),父层的兜底超时会接手,这里不需要再做什么 + }); } } diff --git a/src/app/service/sandbox/runtime.test.ts b/src/app/service/sandbox/runtime.test.ts index 96be8068f..21cf99929 100644 --- a/src/app/service/sandbox/runtime.test.ts +++ b/src/app/service/sandbox/runtime.test.ts @@ -4,6 +4,7 @@ import type { Server } from "@Packages/message/server"; import type { WindowMessage } from "@Packages/message/window_message"; import type { ScriptLoadInfo } from "../service_worker/types"; import type { TExtensionEnv } from "../extension/extension_env"; +import { SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_CRONTAB, type SCRIPT_TYPE } from "@App/app/repo/scripts"; // 单测重点:sandbox runtime.execScript 中新加的 run-in 过滤逻辑 // 通过 mock BgExecScriptWarp 与 offscreen client,观察是否构造执行实例来判断过滤是否生效 @@ -32,11 +33,11 @@ import { Runtime } from "./runtime"; initTestEnv(); -const buildScript = (runIn?: string): ScriptLoadInfo => +const buildScript = (runIn?: string, type: SCRIPT_TYPE = SCRIPT_TYPE_BACKGROUND): ScriptLoadInfo => ({ - uuid: `uuid-${runIn ?? "none"}`, + uuid: `uuid-${type}-${runIn ?? "none"}`, name: "bg", - type: 2, + type, code: "", sourceCode: "", metadata: runIn ? { "run-in": [runIn] } : {}, @@ -101,6 +102,19 @@ describe("Runtime.execScript run-in 过滤", () => { expect(BgExecScriptWarpCtor).toHaveBeenCalledTimes(1); }); + it.each([ + ["后台", "normal-tabs", SCRIPT_TYPE_BACKGROUND], + ["后台", "incognito-tabs", SCRIPT_TYPE_BACKGROUND], + ["定时", "normal-tabs", SCRIPT_TYPE_CRONTAB], + ["定时", "incognito-tabs", SCRIPT_TYPE_CRONTAB], + ] as const)('Firefox spanning 共享事件页不把%s脚本的 run-in === "%s" 伪装成窗口隔离', async (_label, runIn, type) => { + const runtime = setup({ inIncognitoContext: false, incognitoMode: "spanning" }); + + await runtime.execScript(buildScript(runIn, type)); + + expect(BgExecScriptWarpCtor).toHaveBeenCalledTimes(1); + }); + it("extensionEnv 为 undefined 时,过滤被跳过照常执行(fail-open)", async () => { // 当前实现:拿不到 incognito 状态时不做过滤,避免静默丢失任务 const runtime = setup(undefined); diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index f5df396d0..49e215384 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -179,7 +179,10 @@ export class Runtime { // 判断 run-in const runIn = script.metadata?.["run-in"]?.[0]; const inIncognitoContext = extensionEnv?.inIncognitoContext; - if (runIn && runIn !== "all" && typeof inIncognitoContext === "boolean") { + // Firefox spanning 只有一个共享 event page / sandbox,没有按普通窗口与隐身窗口拆分的后台环境。 + // 因此后台/定时脚本只能运行一次;tab 专属的 run-in 值在该共享环境中不做伪隔离。 + const hasPerContextBackground = extensionEnv?.incognitoMode !== "spanning"; + if (hasPerContextBackground && runIn && runIn !== "all" && typeof inIncognitoContext === "boolean") { // 判断插件运行环境 const contextType = inIncognitoContext ? "incognito-tabs" : "normal-tabs"; if (runIn !== contextType) { diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index ac046020d..4a618893e 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -33,8 +33,8 @@ export class ServiceWorkerClient extends Client { super(msgSender, "serviceWorker"); } - preparationOffscreen() { - return this.do("preparationOffscreen"); + preparationOffscreen(data: { verified: boolean }) { + return this.do("preparationOffscreen", data); } } diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 4db971b19..05e2118fe 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -27,6 +27,7 @@ import { extensionEnv, getExtensionUserAgentData } from "../extension/extension_ import { cleanupStaleTempStorageEntries } from "./temp"; import RuntimeLogger from "@App/app/logger/logger"; import LoggerCore from "@App/app/logger/core"; +import { hookFirefoxEventPageKeepAliveLoop, hookServiceWorkerKeepAliveLoop } from "../offscreen/keep_alive"; // service worker的管理器 export default class ServiceWorkerManager { @@ -55,10 +56,10 @@ export default class ServiceWorkerManager { initManager() { this.api.on("logger", this.logger.bind(this)); this.api.on("getExtensionEnv", this.getExtensionEnv.bind(this)); - this.api.on("preparationOffscreen", async () => { + this.api.on("preparationOffscreen", async (data: { verified: boolean }) => { // 准备好环境 await this.offscreenSend.init(); - this.mq.emit("preparationOffscreen", {}); + this.mq.emit("preparationOffscreen", data); }); this.offscreenSend.init(); @@ -70,6 +71,7 @@ export default class ServiceWorkerManager { const localStorageDAO = new LocalStorageDAO(); const systemConfig = new SystemConfig(this.mq); + hookFirefoxEventPageKeepAliveLoop(systemConfig); initLocales(systemConfig); @@ -123,6 +125,11 @@ export default class ServiceWorkerManager { const agent = new AgentService(this.api.group("agent"), this.offscreenSend, resource); agent.init(); + const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function"; + if (hasOffscreenDocument) { + hookServiceWorkerKeepAliveLoop(systemConfig, this.mq, this.offscreenSend); + } + // 注入 AgentService 到 GMApi,使 Agent API 走权限验证通道 const gmApi = runtime.getGMApi(); if (gmApi) { diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 47f0d8365..fe9e84481 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -3,10 +3,15 @@ import { RuntimeService } from "./runtime"; import { vi, describe, it, expect, beforeEach, type MockedFunction } from "vitest"; import { randomUUID } from "crypto"; import type { Script, ScriptRunResource } from "@App/app/repo/scripts"; -import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; +import { + SCRIPT_STATUS_DISABLE, + SCRIPT_STATUS_ENABLE, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; import { buildScriptRunResourceBasic, getCombinedMeta, scriptURLPatternResults } from "./utils"; import type { SystemConfig } from "@App/pkg/config/config"; -import type { Group } from "@Packages/message/server"; +import { SenderRuntime, type Group } from "@Packages/message/server"; import type { ServiceWorkerMessageSend, WindowMessageBody } from "@Packages/message/window_message"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ValueService } from "./value"; @@ -619,9 +624,18 @@ const _createScriptRunResource = (script: Script): ScriptRunResource => { }; const _createRuntimeContext = () => { - const mockSystemConfig = { getBlacklist: vi.fn().mockReturnValue("") }; + const mockSystemConfig = { + getBlacklist: vi.fn().mockReturnValue(""), + getEnableScriptIncognito: vi.fn().mockResolvedValue(true), + getLanguage: vi.fn().mockResolvedValue("zh-CN"), + addListener: vi.fn(), + }; const mockScriptService = { buildScriptRunResource: vi.fn() }; - const mockGroup = { use: vi.fn().mockReturnThis() } as unknown as Group; + const mockGroup = { + use: vi.fn().mockReturnThis(), + emit: vi.fn(), + publish: vi.fn(), + } as unknown as Group; const mockSender = { async init() {}, messageHandle(_data: WindowMessageBody) {}, @@ -696,6 +710,24 @@ describe("shouldSkipPageLoadScript 页面脚本加载过滤规则", () => { expect((runtime as any).shouldSkipPageLoadScript(scriptRes, undefined)).toBe(true); }); + it("run-in: incognito-tabs 在真实隐身标签页中匹配,不跳过", () => { + const { runtime } = _createRuntimeContext(); + const scriptRes = _createScriptRunResource( + _createMockScript({ metadata: { match: ["https://example.com/*"], "run-in": ["incognito-tabs"] } }) + ); + + expect((runtime as any).shouldSkipPageLoadScript(scriptRes, undefined, true)).toBe(false); + }); + + it("run-in: normal-tabs 在真实隐身标签页中不匹配,跳过", () => { + const { runtime } = _createRuntimeContext(); + const scriptRes = _createScriptRunResource( + _createMockScript({ metadata: { match: ["https://example.com/*"], "run-in": ["normal-tabs"] } }) + ); + + expect((runtime as any).shouldSkipPageLoadScript(scriptRes, undefined, true)).toBe(true); + }); + it("run-in: all → 任意环境均不跳过", () => { const { runtime } = _createRuntimeContext(); const scriptRes = _createScriptRunResource( @@ -750,7 +782,7 @@ describe("getScriptsForTab 附加边界场景", () => { /** 带完整 mock 的测试上下文,可按需覆盖各层依赖 */ const createFullContext = (scriptOverrides: Partial