diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 53ed25593..95643e516 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -30,6 +30,7 @@ jobs: - name: Package with Node env: CHROME_PEM: ${{ secrets.CHROME_PEM }} + SC_KEEP_EVENT_PAGE_ACTIVE: "true" run: | mkdir dist echo "$CHROME_PEM" > ./dist/scriptcat.pem diff --git a/.github/workflows/packageRelease.yml b/.github/workflows/packageRelease.yml index 63731a3d0..12181e089 100644 --- a/.github/workflows/packageRelease.yml +++ b/.github/workflows/packageRelease.yml @@ -23,6 +23,7 @@ jobs: - name: Package with Node env: CHROME_PEM: ${{ secrets.CHROME_PEM }} + SC_KEEP_EVENT_PAGE_ACTIVE: "true" run: | mkdir dist echo "$CHROME_PEM" > ./dist/scriptcat.pem diff --git a/docs/architecture.md b/docs/architecture.md index cbe6f5f7a..5e7e2dd88 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,25 @@ 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` — no round trip, no waiting. 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` after + `SANDBOX_READY_FALLBACK_MS`, logging a clear error instead of hanging forever. + +Firefox builds compiled with `SC_KEEP_EVENT_PAGE_ACTIVE=true` also include an experimental event-page keep-alive +loop in `EventPageOffscreenManager`. That loop needs `webRequestBlocking`, so `scripts/pack.js` injects it only +as a Firefox `optional_permissions` entry under the same build flag. Users grant it from the install prompt or +runtime settings when they use Background Scripts or Scheduled Scripts; the event page listens for +`chrome.permissions.onAdded` and starts the loop only after the grant is present. Services never see this difference: they receive an `IOffscreenSend` and call `.init()`. 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..71926b635 100644 --- a/packages/message/message_queue.test.ts +++ b/packages/message/message_queue.test.ts @@ -198,6 +198,30 @@ 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("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..649ab91ec 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.debug("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..ffd12488f 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -15,6 +15,7 @@ const isBeta = version.includes("-"); const isReactTools = process.env.REACT_DEVTOOLS === "true"; // agent 默认开启;正式版屏蔽由 scripts/pack.js 按版本判断后通过 SC_DISABLE_AGENT 声明。 const enableAgent = process.env.SC_DISABLE_AGENT !== "true"; +const keepEventPageActive = process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false"; // Target browsers, see: https://github.com/browserslist/browserslist // 依照 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts#browser_compatibility @@ -138,6 +139,7 @@ export default { "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, + "process.env.SC_KEEP_EVENT_PAGE_ACTIVE": `'${keepEventPageActive ? "true" : "false"}'`, // 正式 Release 需设为 true 避免定时脚本失效 }), new rspack.CopyRspackPlugin({ patterns: [ @@ -155,9 +157,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..12cf683a1 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,44 @@ 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; +delete chromeManifest.content_security_policy.sandbox; // chromeManifest 不需要? +if (chromeManifest.content_security_policy && Object.keys(chromeManifest.content_security_policy).length === 0) { + delete chromeManifest.content_security_policy; +} + +// 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" +); + +if (process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false") { + // for startFirefoxEventPageKeepAliveLoop — 运行时按需向用户申请 + 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 +113,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/offscreen/base.test.ts b/src/app/service/offscreen/base.test.ts new file mode 100644 index 000000000..44c7b416b --- /dev/null +++ b/src/app/service/offscreen/base.test.ts @@ -0,0 +1,111 @@ +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 } from "@Packages/message/types"; +import { BackgroundEnvManagerBase, SANDBOX_READY_FALLBACK_MS } from "./base"; + +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 }; +}; + +// 单测重点:就绪信号完全由 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); + }); + + 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); + }); + + 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("兜底超时先触发后,迟到的真实握手不会重复通知 SW 就绪", () => { + vi.useFakeTimers(); + const { manager, preparationOffscreen } = buildManager(); + + (manager as unknown as { armReadyFallback(): void }).armReadyFallback(); + vi.advanceTimersByTime(SANDBOX_READY_FALLBACK_MS); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + + manager.preparationSandbox(); + expect(preparationOffscreen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index 90ed1cb5b..a88d3333c 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -1,28 +1,50 @@ 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" }); + + // 确保"通知 SW 环境就绪"只执行一次:可能由真实的 sandbox 握手触发,也可能由兜底超时触发, + // 二者存在竞态,缺少这个防重入会导致 preparationOffscreen 被重复调用 + private offscreenReadyNotified = 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,10 +53,47 @@ export class BackgroundEnvManagerBase { } preparationSandbox() { + // sandbox 主动通知自己已就绪(而非由父层猜测/轮询/ping 探测)。 + // Firefox 154+ 下 sandbox manifest 页面是跨源 iframe:contentDocument 为 null, + // contentWindow.location 不可读,父层没有别的办法探测其就绪状态,也不该去 ping sandbox—— + // 只有 sandbox 自己知道它什么时候真正就绪。sandbox 还会自行做一次通道自检, + // 结果通过 reportSandboxChannelHealth 单独上报(见下)。 + this.notifyOffscreenReady("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(reason: string) { + if (this.offscreenReadyNotified) { + return; + } + this.offscreenReadyNotified = true; + this.handshakeLogger.debug(`offscreen ready (${reason})`); // 通知初始化好环境了 this.serviceWorker.preparationOffscreen(); } + // 兜底:sandbox 若因 iframe 加载失败/脚本异常等原因从未发出就绪通知,也不能让 SW 永久 + // 卡在等待 offscreen 就绪上 —— 超时后仍然放行,只是没有经过连通性验证 + private armReadyFallback() { + setTimeout(() => { + if (!this.offscreenReadyNotified) { + this.handshakeLogger.error( + `no sandbox readiness signal received within ${SANDBOX_READY_FALLBACK_MS}ms; proceeding without a verified sandbox channel` + ); + this.notifyOffscreenReady("fallback timeout, sandbox never reported readiness"); + } + }, SANDBOX_READY_FALLBACK_MS); + } + async getExtensionEnv(data: { requireUAD: boolean }) { return this.sendMessageToServiceWorker({ action: "getExtensionEnv", @@ -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..93a9fab81 --- /dev/null +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -0,0 +1,197 @@ +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(); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.stubGlobal("scheduler", undefined); + vi.restoreAllMocks(); + chromeMock.init(); + for (const node of Array.from(document.documentElement.querySelectorAll("iframe,img"))) { + node.remove(); + } +}); + +const loadKeepAliveEnabledManager = async () => { + vi.stubEnv("SC_KEEP_EVENT_PAGE_ACTIVE", "true"); + vi.stubGlobal("scheduler", { + postTask: vi.fn(), + }); + 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 import("./event_page_manager.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("权限未授予时不注册 blocking listener,也不发起首拍探测", async () => { + chromeMock.permissions.__setGrantedPermissions([]); + const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = + await loadKeepAliveEnabledManager(); + + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + await Promise.resolve(); + + expect((chrome.webRequest.onBeforeRequest as any).listeners).toHaveLength(0); + expect(document.documentElement.querySelectorAll("img")).toHaveLength(0); + }); + + it("权限已授予时注册 blocking listener 并发起首拍探测", async () => { + chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); + const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = + await loadKeepAliveEnabledManager(); + + 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(document.documentElement.querySelectorAll("img")).toHaveLength(1); + }); + + it("运行中收到 webRequestBlocking 授权后启动循环,重复触发不会重复注册", async () => { + chromeMock.permissions.__setGrantedPermissions([]); + const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = + await loadKeepAliveEnabledManager(); + + 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(document.documentElement.querySelectorAll("img")).toHaveLength(1); + }); + + it("移除 webRequestBlocking 后立即停止 blocking 探测,重新授权后恢复", async () => { + chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); + const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = + await loadKeepAliveEnabledManager(); + + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + await Promise.resolve(); + + const listener = (chrome.webRequest.onBeforeRequest as any).listeners[0].callback; + const probeRequest = { + url: "https://--extensions-test.invalid/dummy_image.png?__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(document.documentElement.querySelectorAll("img")).toHaveLength(1); + }); + + 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 } = + await loadKeepAliveEnabledManager(); + + new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); + + expect(addListener).toHaveBeenCalledTimes(1); + expect(contains).toHaveBeenCalledTimes(1); + expect(addListener.mock.invocationCallOrder[0]).toBeLessThan(contains.mock.invocationCallOrder[0]); + }); +}); diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index eff5f8dcb..f398d2ec1 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -9,8 +9,10 @@ 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"; +import { startFirefoxEventPageKeepAliveLoop } from "./keep_alive"; class InProcessMessageConnect implements MessageConnect { private messages = new EventEmitter(); @@ -48,7 +50,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 +89,39 @@ 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."); - } + startFirefoxEventPageKeepAliveLoop(); 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..85b3acf53 --- /dev/null +++ b/src/app/service/offscreen/keep_alive.ts @@ -0,0 +1,332 @@ +// 保活实验依赖 Scheduler API:带 delay 的 user-visible task 相比 setTimeout 更不容易在后台降频。 + +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"; + +let selfSw: ServiceWorkerGlobalScope | null = null; + +export const onServiceWorkerStarted = (sw: ServiceWorkerGlobalScope) => { + // assign the varible `selfSw` for later use. + selfSw = sw; +}; + +const deferredResponse = (o: T, delayMs: number) => + new Promise((resolve) => { + // user-visible 优先级用于尽量减少后台页面降频;它不保证永久存活。 + nativeScheduler.postTask( + () => { + resolve(o); + }, + { priority: "user-visible", delay: delayMs } + ); + }); + +// 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 +const getKeepAliveProbeUrl = () => `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; + +// API 不可用时直接关闭实验,避免引入另一套行为不一致的计时路径。 +const nativeScheduler = + //@ts-ignore + typeof scheduler !== "undefined" && typeof scheduler?.postTask === "function" && scheduler; + +/** + * Firefox MV3 event page 保活实验(默认关闭)。 + * + * 背景:Firefox 没有 Chrome 的 offscreen document;event page 被挂起时,sandbox iframe 及其 + * 内存态定时任务会一并销毁,当前代码不会在恢复后重建这些 `@crontab` 调度。 + * + * 机制:使用 `webRequestBlocking` 暂停一个扩展自身发起的探测请求,并用 + * `scheduler.postTask()` 延迟返回 blocking 响应;`` 在请求结束后继续下一轮。 + * 未完成网络请求可能阻止 event page 被判定为空闲。 + * + * 启用条件: + * - 构建时设置 `SC_KEEP_EVENT_PAGE_ACTIVE=true`; + * - Firefox manifest 在同一构建开关下把 `webRequestBlocking` 注入 `optional_permissions`; + * - 用户通过安装提示或设置页授予 `webRequestBlocking`; + * - 浏览器提供 `scheduler.postTask`。 + * + * 这不是 Firefox 保证的生命周期机制。任一条件不满足时函数为空操作;探测未被实际延迟时, + * `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 onKeepAliveProbeSettled = boolFirefox + ? (function (this: HTMLImageElement, ev: Event) { + ev.preventDefault(); + ev.stopPropagation(); + ev.stopImmediatePropagation(); + if (image === this) { + // 上一輪的 image 可以被 GC 了 + image.onload = image.onerror = null; + image = null; + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免忙循环。 + if (!enabled || Date.now() - probeStartedAt < KEEP_ALIVE_MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + // 下一轮 + sendKeepAliveProbe(); + } + } as any) + : (x: any) => { + if (image === x) { + image = null; + + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免忙循环。 + if (!enabled || Date.now() - probeStartedAt < KEEP_ALIVE_MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + // 下一轮 + sendKeepAliveProbe(); + } + }; + + // 发起一次探测请求,作为心跳循环的一拍 + const sendKeepAliveProbe = boolFirefox + ? () => { + // Image 特性:网络要求发起不需要跟随 DOM 节点附付 + image = new Image(1, 1); + probeStartedAt = Date.now(); + + // onload / onerror 在呼叫前 image 不会被 GC + image.onload = onKeepAliveProbeSettled; + image.onerror = onKeepAliveProbeSettled; + + image.src = `${keepAliveProbeUrl}?__network_delay=${KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; + } + : () => { + image = new Image(1, 1); + probeStartedAt = Date.now(); + image.src = `data:image/gif;base64,${TRANSPARENT_GIF_BASE64}`; + deferredResponse(image, KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS).then(onKeepAliveProbeSettled); + }; + + return { + start() { + if (enabled) return; + enabled = true; + sendKeepAliveProbe(); + }, + stop() { + enabled = false; + }, + isEnabled() { + return enabled; + }, + }; +}; + +const TRANSPARENT_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +export const startFirefoxEventPageKeepAliveLoop = + boolFirefox && process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false" && nativeScheduler + ? () => { + let running = 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 startLoop = () => { + 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")) startLoop(); + }); + + 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")) keepAlive.stop(); + }); + + void chrome.permissions.contains({ permissions: ["webRequestBlocking"] }).then((granted) => { + if (granted) startLoop(); + }); + } + : () => {}; + +/** + * Chrome MV3 service worker 保活实验:仅注册 ServiceWorkerGlobalScope 事件。 + * + * service worker 没有 DOM,不能创建 `Image`/访问 `document`;发起探测请求的职责在 + * {@link startChromeOffscreenKeepAliveLoop}(offscreen document 里有 DOM)。这里只负责拦截 + * 探测请求并用 `scheduler.postTask()` 延迟响应,使该请求在 offscreen 侧保持未完成状态。 + * + * Chrome 要求 `install`/`activate`/`fetch` 监听器必须在 worker 脚本初次同步求值时注册, + * 否则会报 "Event handler ... must be added on the initial evaluation of worker script" 并 + * 丢弃事件。因此监听器在调用时立即、无条件注册;是否真正拦截探测请求由返回的 setter 控制的 + * `enabled` 标志决定,setter 可在配置项异步加载或变更后随时调用。 + */ +const startChromeServiceWorkerKeepAliveLoop = + !boolFirefox && nativeScheduler + ? () => { + const selfSw_ = selfSw; + if (!selfSw_) return () => {}; + + let enabled = false; + + const keepAliveProbeUrl = getKeepAliveProbeUrl(); + + // TRANSPARENT_GIF_BASE64 + const gifBytes = new Uint8Array([ + 71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 128, 0, 0, 0, 0, 0, 255, 255, 255, 33, 249, 4, 1, 0, 0, 0, 0, 44, 0, 0, 0, + 0, 1, 0, 1, 0, 0, 2, 2, 68, 1, 0, 59, + ]); + + selfSw_.addEventListener("fetch", (event: FetchEvent) => { + if (!enabled || !event.request.url.includes(keepAliveProbeUrl)) { + return void 0; + } + const url = new URL(event.request.url); + + // 只延迟明确带有延迟标记的请求,避免误伤同源下的其它请求 + if (!url.searchParams.has("__network_delay")) { + return void 0; + } + + 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 = new Response(gifBytes, { + status: 200, + statusText: "OK", + headers: { + "Content-Type": "image/gif", + "Content-Length": gifBytes.length.toString(), + "Cache-Control": "no-store, must-revalidate", // Prevent browser caching during tests + }, + }); + event.respondWith(deferredResponse(response, delayMs)); + }); + + return (val: boolean) => { + enabled = val; + }; + } + : () => (_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", () => { + offscreenReady = true; + informOffscreen(); + }); +}; + +/** + * Chrome MV3 offscreen document 保活实验:负责实际发起探测请求。 + * + * offscreen document 具备 DOM,可以用 `` 发起请求;请求会被 + * {@link startChromeServiceWorkerKeepAliveLoop} 中注册的 SW `fetch` 监听器拦截并延迟响应, + * 未完成的探测请求用于减少 service worker 被判定为空闲的概率。 + * + * 返回一个 setter:配置项初次异步加载完成或用户在设置页切换开关时都会调用它, + * 从关闭切到开启时立即发起新一轮探测循环,切回关闭时让 `onKeepAliveProbeSettled` 中的 + * `enabled` 检查在当前这次探测结束后自然停止循环。 + */ +export const startChromeOffscreenKeepAliveLoop = + !boolFirefox && nativeScheduler + ? () => { + // 实际往返过短说明请求未被 SW 端延迟(通常是 fetch 监听器未注册或请求提前失败); + // 此时停止后续探测,避免失败请求形成紧密循环。 + const keepAliveProbeUrl = getKeepAliveProbeUrl(); + const keepAlive = createKeepAliveProbeLoop(keepAliveProbeUrl); + + return (val: boolean) => { + if (val) { + keepAlive.start(); + return; + } + keepAlive.stop(); + }; + } + : () => (_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/service_worker/index.ts b/src/app/service/service_worker/index.ts index e06082a9c..730aa8d2f 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -25,6 +25,7 @@ import { InfoNotification, shouldAutoOpenChangelog } from "./utils"; import { AgentService } from "@App/app/service/agent/service_worker/agent"; import { extensionEnv, getExtensionUserAgentData } from "../extension/extension_env"; import { cleanupStaleTempStorageEntries } from "./temp"; +import { hookServiceWorkerKeepAliveLoop } from "../offscreen/keep_alive"; // service worker的管理器 export default class ServiceWorkerManager { @@ -119,6 +120,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/script.ts b/src/app/service/service_worker/script.ts index a2271336e..8c8741221 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -559,12 +559,12 @@ export class ScriptService { updatetime: Date.now(), }) .then(() => { - logger.info("enable success"); + logger.info(enable ? "enable success" : "disable success"); this.mq.publish("enableScripts", [{ uuid: uuid, enable: enable }]); return {}; }) .catch((e) => { - logger.error("enable error", Logger.E(e)); + logger.error(enable ? "enable error" : "disable error", Logger.E(e)); throw e; }); } @@ -590,7 +590,7 @@ export class ScriptService { updatetime: Date.now(), }) .then(() => { - logger.info("enable success"); + logger.info(enable ? "enable success" : "disable success"); this.mq.publish( "enableScripts", uuids2.map((uuid) => ({ uuid, enable })) @@ -598,7 +598,7 @@ export class ScriptService { return {}; }) .catch((e) => { - logger.error("enable error", Logger.E(e)); + logger.error(enable ? "enable error" : "disable error", Logger.E(e)); throw e; }); } diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 466ef7e2e..75d6ca79c 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -95,13 +95,28 @@ "title": "Hintergrundausführung aktivieren", "description": "Wenn aktiviert, bleibt der Browser auch nach dem Schließen aller Fenster im Hintergrund aktiv und minimiert sich in den Tray, bis Sie den Browser manuell beenden. Dadurch können Hintergrundskripte weiterhin ausgeführt werden.", "enable_failed": "Aktivierung fehlgeschlagen", + "enable_success": "Aktiviert", "disable_failed": "Deaktivierung fehlgeschlagen", + "disable_success": "Deaktiviert", "prompt_title": "Hintergrundausführung aktivieren?", "prompt_description": "Dies ist ein {{scriptType}}. Die Aktivierung der Hintergrundausführung ermöglicht es dem Skript, nach dem Schließen des Browsers weiter zu laufen.", "enable_now": "Jetzt aktivieren", "maybe_later": "Vielleicht später", "settings_hint": "Sie können diese Option jederzeit in den Einstellungen ändern." }, + "keep_scripts_alive": { + "title": "Hintergrund- und geplante Skripte aktiv halten", + "description": "Hält die Hintergrundausführungsumgebung von ScriptCat aktiv, damit Hintergrundskripte und geplante Skripte weiterlaufen. Diese Funktion ist experimentell, wird vom Browser nicht garantiert und kann etwas mehr Ressourcen verwenden.", + "enable_failed": "Aktivierung fehlgeschlagen", + "enable_success": "Aktiviert", + "disable_failed": "Deaktivierung fehlgeschlagen", + "disable_success": "Deaktiviert", + "prompt_title": "Skripte in Firefox aktiv halten?", + "prompt_description": "Dies ist ein {{scriptType}}. Erlauben Sie ScriptCat, die Firefox-Hintergrundseite aktiv zu halten, damit das Skript nach Zeitplan weiterlaufen kann. Diese Funktion ist experimentell und kann etwas mehr Ressourcen verwenden.", + "enable_now": "Jetzt aktivieren", + "maybe_later": "Vielleicht später", + "settings_hint": "Sie können diese Option jederzeit in den Einstellungen ändern." + }, "favicon_service": "Favicon-Dienst", "favicon_service_desc": "Dienst zum Abrufen von Website-Symbolen auswählen", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index cedcce7f0..821f9d0e8 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -95,13 +95,28 @@ "title": "Enable Background Running", "description": "When enabled, the browser stays running in the background after you close all windows, minimizing to the tray until you manually quit Chrome. This allows background scripts to keep running.", "enable_failed": "Failed to enable", + "enable_success": "Enabled successfully", "disable_failed": "Failed to disable", + "disable_success": "Disabled successfully", "prompt_title": "Enable Background Running?", "prompt_description": "This is a {{scriptType}}. Enabling background running allows the script to continue running after the browser is closed.", "enable_now": "Enable Now", "maybe_later": "Maybe Later", "settings_hint": "You can change this option in settings at any time." }, + "keep_scripts_alive": { + "title": "Keep Background and Scheduled Scripts Alive", + "description": "Keeps ScriptCat's background execution environment active so Background Scripts and Scheduled Scripts continue to run. This is experimental, not guaranteed by the browser, and may use slightly more resources.", + "enable_failed": "Failed to enable", + "enable_success": "Enabled successfully", + "disable_failed": "Failed to disable", + "disable_success": "Disabled successfully", + "prompt_title": "Keep Scripts Alive on Firefox?", + "prompt_description": "This is a {{scriptType}}. Allow ScriptCat to keep its Firefox background page active so the script can continue running on schedule. This is experimental and may use slightly more resources.", + "enable_now": "Enable Now", + "maybe_later": "Maybe Later", + "settings_hint": "You can change this option in settings at any time." + }, "favicon_service": "Favicon Service", "favicon_service_desc": "Choose the service for fetching website icons", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index af4d89eca..253298449 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -95,13 +95,28 @@ "title": "バックグラウンド実行を有効にする", "description": "有効にすると、すべてのウィンドウを閉じた後もブラウザはバックグラウンドで動作し、手動で終了するまでトレイに最小化されます。これにより、バックグラウンドスクリプトの実行が続きます。", "enable_failed": "有効化に失敗しました", + "enable_success": "有効化しました", "disable_failed": "無効化に失敗しました", + "disable_success": "無効化しました", "prompt_title": "バックグラウンド実行を有効にしますか?", "prompt_description": "これは{{scriptType}}です。バックグラウンド実行を有効にすると、ブラウザを閉じた後もスクリプトの実行が続きます。", "enable_now": "今すぐ有効にする", "maybe_later": "後で", "settings_hint": "設定ページでいつでも変更できます。" }, + "keep_scripts_alive": { + "title": "バックグラウンドスクリプトとスケジュールスクリプトを維持", + "description": "ScriptCat のバックグラウンド実行環境をアクティブに保ち、バックグラウンドスクリプトとスケジュールスクリプトの実行を続けます。この機能は実験的で、ブラウザーが保証する仕組みではなく、リソース使用量が少し増える場合があります。", + "enable_failed": "有効化に失敗しました", + "enable_success": "有効化しました", + "disable_failed": "無効化に失敗しました", + "disable_success": "無効化しました", + "prompt_title": "Firefox でスクリプトを維持しますか?", + "prompt_description": "これは{{scriptType}}です。ScriptCat が Firefox のバックグラウンドページをアクティブに保つことを許可すると、スクリプトはスケジュールどおりに実行を続けられます。この機能は実験的で、リソース使用量が少し増える場合があります。", + "enable_now": "今すぐ有効にする", + "maybe_later": "後で", + "settings_hint": "設定ページでいつでも変更できます。" + }, "favicon_service": "Favicon サービス", "favicon_service_desc": "ウェブサイトアイコンの取得サービスを選択", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 5229765ff..ffee875e7 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -95,13 +95,28 @@ "title": "Включить фоновый режим", "description": "Если включено, браузер продолжит работать в фоновом режиме после закрытия всех окон и будет свернут в трей, пока вы вручную не завершите его работу. Это позволяет фоновым скриптам продолжать выполняться.", "enable_failed": "Не удалось включить", + "enable_success": "Включено", "disable_failed": "Не удалось отключить", + "disable_success": "Отключено", "prompt_title": "Включить фоновое выполнение?", "prompt_description": "Это {{scriptType}}. Включение фонового выполнения позволит скрипту продолжать работать после закрытия браузера.", "enable_now": "Включить сейчас", "maybe_later": "Может быть позже", "settings_hint": "Вы можете изменить эту опцию в настройках в любое время." }, + "keep_scripts_alive": { + "title": "Поддерживать фоновые скрипты и скрипты по расписанию", + "description": "Поддерживает активность фоновой среды ScriptCat, чтобы фоновые скрипты и скрипты по расписанию продолжали выполняться. Функция экспериментальная, не гарантируется браузером и может немного увеличить расход ресурсов.", + "enable_failed": "Не удалось включить", + "enable_success": "Включено", + "disable_failed": "Не удалось отключить", + "disable_success": "Отключено", + "prompt_title": "Поддерживать скрипты в Firefox?", + "prompt_description": "Это {{scriptType}}. Разрешите ScriptCat поддерживать активность фоновой страницы Firefox, чтобы скрипт продолжал выполняться по расписанию. Функция экспериментальная и может немного увеличить расход ресурсов.", + "enable_now": "Включить сейчас", + "maybe_later": "Может быть позже", + "settings_hint": "Вы можете изменить эту опцию в настройках в любое время." + }, "favicon_service": "Сервис Favicon", "favicon_service_desc": "Выберите сервис для получения значков сайтов", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index dcdda8689..3cff36b79 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -95,13 +95,28 @@ "title": "Arka Planda Çalıştırmayı Etkinleştir", "description": "Etkinleştirildiğinde, tüm pencereleri kapattıktan sonra tarayıcı arka planda çalışmaya devam eder ve siz Chrome'dan manuel olarak çıkana kadar sistem tepsisine küçültülür. Bu, arka plan betiklerinin çalışmaya devam etmesini sağlar.", "enable_failed": "Etkinleştirilemedi", + "enable_success": "Etkinleştirildi", "disable_failed": "Devre Dışı Bırakılamadı", + "disable_success": "Devre Dışı Bırakıldı", "prompt_title": "Arka Planda Çalıştırma Etkinleştirilsin mi?", "prompt_description": "Bu bir {{scriptType}}. Arka planda çalıştırmanın etkinleştirilmesi, tarayıcı kapatıldıktan sonra betiğin çalışmaya devam etmesine olanak tanır.", "enable_now": "Şimdi Etkinleştir", "maybe_later": "Belki Daha Sonra", "settings_hint": "Bu seçeneği istediğiniz zaman ayarlardan değiştirebilirsiniz." }, + "keep_scripts_alive": { + "title": "Arka Plan ve Zamanlanmış Betikleri Canlı Tut", + "description": "ScriptCat'in arka plan çalışma ortamını etkin tutarak Arka Plan Betikleri ve Zamanlanmış Betiklerin çalışmaya devam etmesini sağlar. Bu özellik deneyseldir, tarayıcı tarafından garanti edilmez ve biraz daha fazla kaynak kullanabilir.", + "enable_failed": "Etkinleştirilemedi", + "enable_success": "Etkinleştirildi", + "disable_failed": "Devre Dışı Bırakılamadı", + "disable_success": "Devre Dışı Bırakıldı", + "prompt_title": "Firefox'ta Betikler Canlı Tutulsun mu?", + "prompt_description": "Bu bir {{scriptType}}. Betiğin zamanında çalışmaya devam etmesi için ScriptCat'in Firefox arka plan sayfasını etkin tutmasına izin verin. Bu özellik deneyseldir ve biraz daha fazla kaynak kullanabilir.", + "enable_now": "Şimdi Etkinleştir", + "maybe_later": "Belki Daha Sonra", + "settings_hint": "Bu seçeneği istediğiniz zaman ayarlardan değiştirebilirsiniz." + }, "favicon_service": "Site Simgesi Hizmeti", "favicon_service_desc": "Web sitesi simgelerini getirecek hizmeti seçin", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 053c1b176..65be15bb4 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -95,13 +95,28 @@ "title": "Bật chạy nền", "description": "Khi bật, trình duyệt sẽ tiếp tục chạy trong nền sau khi bạn đóng tất cả các cửa sổ, thu nhỏ xuống khay hệ thống cho đến khi bạn thoát trình duyệt thủ công. Điều này cho phép các script chạy nền tiếp tục hoạt động.", "enable_failed": "Bật thất bại", + "enable_success": "Đã bật", "disable_failed": "Tắt thất bại", + "disable_success": "Đã tắt", "prompt_title": "Bật chạy nền?", "prompt_description": "Đây là {{scriptType}}. Bật chạy nền cho phép script tiếp tục chạy sau khi đóng trình duyệt.", "enable_now": "Bật ngay", "maybe_later": "Để sau", "settings_hint": "Bạn có thể thay đổi tùy chọn này trong cài đặt bất kỳ lúc nào." }, + "keep_scripts_alive": { + "title": "Giữ script nền và script hẹn giờ hoạt động", + "description": "Giữ môi trường chạy nền của ScriptCat hoạt động để script nền và script hẹn giờ tiếp tục chạy. Tính năng này còn thử nghiệm, không được trình duyệt bảo đảm và có thể dùng thêm một ít tài nguyên.", + "enable_failed": "Bật thất bại", + "enable_success": "Đã bật", + "disable_failed": "Tắt thất bại", + "disable_success": "Đã tắt", + "prompt_title": "Giữ script hoạt động trên Firefox?", + "prompt_description": "Đây là {{scriptType}}. Cho phép ScriptCat giữ trang nền của Firefox hoạt động để script có thể tiếp tục chạy theo lịch. Tính năng này còn thử nghiệm và có thể dùng thêm một ít tài nguyên.", + "enable_now": "Bật ngay", + "maybe_later": "Để sau", + "settings_hint": "Bạn có thể thay đổi tùy chọn này trong cài đặt bất kỳ lúc nào." + }, "favicon_service": "Dịch vụ Favicon", "favicon_service_desc": "Chọn dịch vụ để lấy biểu tượng trang web", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index b4740e22d..8751ffa18 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -95,13 +95,28 @@ "title": "启用后台运行", "description": "启用后,即使关闭所有窗口,浏览器仍会在后台运行,并最小化到系统托盘,直到您手动退出浏览器。这使后台脚本能够继续运行。", "enable_failed": "启用失败", + "enable_success": "启用成功", "disable_failed": "禁用失败", + "disable_success": "禁用成功", "prompt_title": "是否开启后台运行?", "prompt_description": "此脚本是{{scriptType}},启用后台运行功能可以让脚本在浏览器关闭后继续运行。", "enable_now": "立即启用", "maybe_later": "暂不启用", "settings_hint": "你可以随时在设置中修改此选项。" }, + "keep_scripts_alive": { + "title": "保持后台和定时脚本运行", + "description": "保持脚本猫的后台运行环境活跃,让后台脚本和定时脚本继续运行。此功能仍属实验性质,浏览器不保证一定有效,并可能略微增加资源占用。", + "enable_failed": "启用失败", + "enable_success": "启用成功", + "disable_failed": "禁用失败", + "disable_success": "禁用成功", + "prompt_title": "是否在 Firefox 上保持脚本运行?", + "prompt_description": "此脚本是{{scriptType}}。允许脚本猫保持 Firefox 后台页面活跃后,脚本可继续按计划运行。此功能仍属实验性质,并可能略微增加资源占用。", + "enable_now": "立即启用", + "maybe_later": "暂不启用", + "settings_hint": "你可以随时在设置中修改此选项。" + }, "favicon_service": "图标服务", "favicon_service_desc": "选择获取网站图标的服务", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index e3196d38f..06ac8eaad 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -95,13 +95,28 @@ "title": "啟用背景運行", "description": "啟用後,即使關閉所有視窗,瀏覽器仍會在背景執行,並最小化到系統匣中,直到您手動退出瀏覽器。這能讓背景腳本繼續運作。", "enable_failed": "啟用失敗", + "enable_success": "啟用成功", "disable_failed": "禁用失敗", + "disable_success": "禁用成功", "prompt_title": "是否開啟背景運行?", "prompt_description": "此腳本是{{scriptType}},啟用背景運行功能可以讓腳本在瀏覽器關閉後繼續運行。", "enable_now": "立即啟用", "maybe_later": "暫不啟用", "settings_hint": "你可以隨時在設定中修改此選項。" }, + "keep_scripts_alive": { + "title": "保持背景與定時腳本執行", + "description": "保持 ScriptCat 的背景執行環境作用中,讓背景腳本與定時腳本繼續執行。此功能仍屬實驗性質,瀏覽器不保證一定有效,且可能略微增加資源用量。", + "enable_failed": "啟用失敗", + "enable_success": "啟用成功", + "disable_failed": "停用失敗", + "disable_success": "停用成功", + "prompt_title": "是否在 Firefox 上保持腳本執行?", + "prompt_description": "此腳本是{{scriptType}}。允許 ScriptCat 保持 Firefox 背景頁面作用中後,腳本可繼續按排程執行。此功能仍屬實驗性質,且可能略微增加資源用量。", + "enable_now": "立即啟用", + "maybe_later": "暫不啟用", + "settings_hint": "你可以隨時在設定中修改此選項。" + }, "favicon_service": "圖示服務", "favicon_service_desc": "選擇取得網站圖示的服務", "favicon_service_scriptcat": "ScriptCat", diff --git a/src/manifest.json b/src/manifest.json index 41ebd002a..dc5f69456 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -48,6 +48,9 @@ "sandbox": { "pages": ["src/sandbox.html"] }, + "content_security_policy": { + "sandbox": "sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols; script-src 'unsafe-inline' 'unsafe-eval' https: http: data: blob: 'self';" + }, "web_accessible_resources": [ { "resources": ["/src/install.html"], diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 500051cd5..f24d63436 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -13,9 +13,11 @@ import { InstallActions } from "./components/InstallActions"; import { InstallWarning } from "./components/InstallWarning"; import { InstallLoading, InstallError } from "./components/InstallStates"; import { WatchingBanner } from "./components/WatchingBanner"; -import { BackgroundPrompt, backgroundPromptShownKey } from "./components/BackgroundPrompt"; +import { BackgroundPrompt, backgroundPromptShownKey, keepAlivePromptShownKey } from "./components/BackgroundPrompt"; import { useInstallData } from "./useInstallData"; +type PromptPermission = "background" | "webRequestBlocking"; + export default function App() { const { t } = useTranslation(["install", "common"]); const isMobile = useIsMobile(); @@ -34,19 +36,26 @@ export default function App() { cancelSkill, retry, } = useInstallData(); - const [bgPrompt, setBgPrompt] = useState<{ scriptType: string } | null>(null); + const [bgPrompt, setBgPrompt] = useState<{ scriptType: string; permission: PromptPermission } | null>(null); // 后台/定时脚本首次安装时,提示开启后台运行(对照 v1.4 checkBackgroundPrompt) const ready = state.status === "ready" ? state.view : null; const schedule = ready?.schedule; useEffect(() => { if (!ready || ready.isSubscribe || !schedule) return; - if (localStorage.getItem(backgroundPromptShownKey) === "true") return; let cancelled = false; - void isPermissionOk("background").then((ok) => { - if (!cancelled && ok === false) { + void Promise.all([isPermissionOk("background"), isPermissionOk("webRequestBlocking")]).then(([bg, wrb]) => { + if (cancelled) return; + const scriptType = schedule.kind === "cron" ? t("install:scheduled_script") : t("install:background_script"); + if (bg === false && localStorage.getItem(backgroundPromptShownKey) !== "true") { + setBgPrompt({ + scriptType, + permission: "background", + }); + } else if (wrb === false && localStorage.getItem(keepAlivePromptShownKey) !== "true") { setBgPrompt({ - scriptType: schedule.kind === "cron" ? t("install:scheduled_script") : t("install:background_script"), + scriptType, + permission: "webRequestBlocking", }); } }); @@ -136,7 +145,12 @@ export default function App() { /> - setBgPrompt(null)} /> + setBgPrompt(null)} + /> ); } diff --git a/src/pages/install/components/BackgroundPrompt.test.tsx b/src/pages/install/components/BackgroundPrompt.test.tsx index 290226b76..c4d65cd51 100644 --- a/src/pages/install/components/BackgroundPrompt.test.tsx +++ b/src/pages/install/components/BackgroundPrompt.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { act, render, screen, cleanup, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; -import { BackgroundPrompt, backgroundPromptShownKey } from "./BackgroundPrompt"; +import { BackgroundPrompt, backgroundPromptShownKey, keepAlivePromptShownKey } from "./BackgroundPrompt"; beforeAll(() => initTestLanguage("zh-CN")); @@ -29,6 +29,17 @@ describe("BackgroundPrompt 后台权限弹窗", () => { expect(localStorage.getItem(backgroundPromptShownKey)).toBe("true"); }); + it("传入 webRequestBlocking 时请求保活权限并使用独立展示标记", async () => { + const req = vi.spyOn(chrome.permissions, "request"); + const onResult = vi.fn(); + render(); + await act(async () => fireEvent.click(screen.getByText("立即启用").closest("button")!)); + expect(onResult).toHaveBeenCalledWith(true); + expect(req).toHaveBeenCalledWith({ permissions: ["webRequestBlocking"] }); + expect(localStorage.getItem(keepAlivePromptShownKey)).toBe("true"); + expect(localStorage.getItem(backgroundPromptShownKey)).toBeNull(); + }); + it("点击暂不启用回调 false 且记录已展示", () => { const onResult = vi.fn(); render(); diff --git a/src/pages/install/components/BackgroundPrompt.tsx b/src/pages/install/components/BackgroundPrompt.tsx index 96ac3de63..e91dacbda 100644 --- a/src/pages/install/components/BackgroundPrompt.tsx +++ b/src/pages/install/components/BackgroundPrompt.tsx @@ -11,26 +11,43 @@ import { } from "@App/pages/components/ui/dialog"; export const backgroundPromptShownKey = "background_prompt_shown"; +export const keepAlivePromptShownKey = "webrequest_blocking_prompt_shown"; + +type PromptPermission = "background" | "webRequestBlocking"; + +const promptConfig: Record = { + background: { + keyPrefix: "enable_background", + shownKey: backgroundPromptShownKey, + }, + webRequestBlocking: { + keyPrefix: "keep_scripts_alive", + shownKey: keepAlivePromptShownKey, + }, +}; export function BackgroundPrompt({ open, scriptType, + permission = "background", onResult, }: { open: boolean; scriptType: string; + permission?: PromptPermission; onResult: (enabled: boolean) => void; }) { const { t } = useTranslation(["settings", "common"]); + const config = promptConfig[permission]; const enable = async () => { - localStorage.setItem(backgroundPromptShownKey, "true"); - const granted = await chrome.permissions.request({ permissions: ["background"] }).catch(() => false); + localStorage.setItem(config.shownKey, "true"); + const granted = await chrome.permissions.request({ permissions: [permission] }).catch(() => false); onResult(!!granted); }; const later = () => { - localStorage.setItem(backgroundPromptShownKey, "true"); + localStorage.setItem(config.shownKey, "true"); onResult(false); }; @@ -47,18 +64,18 @@ export function BackgroundPrompt({ - {t("settings:enable_background.prompt_title")} + {t(`settings:${config.keyPrefix}.prompt_title`)} - {t("settings:enable_background.prompt_description", { scriptType })} + {t(`settings:${config.keyPrefix}.prompt_description`, { scriptType })} -

{t("settings:enable_background.settings_hint")}

+

{t(`settings:${config.keyPrefix}.settings_hint`)}

- + diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx index 188d9990c..4895740a0 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { beforeAll, describe, it, expect, vi, afterEach } from "vitest"; import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; const { create } = vi.hoisted(() => ({ create: vi.fn(() => @@ -33,7 +34,13 @@ vi.mock("@Packages/filesystem/auth", () => ({ ClearNetDiskToken: vi.fn(() => Promise.resolve()), })); -const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +const { get, set, isPermissionOk, isFirefoxMock } = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + isPermissionOk: vi.fn((permission: string) => Promise.resolve(permission === "webRequestBlocking" ? null : false)), + // 默认沿用 jsdom 下的真实判断(非 Firefox),仅在需要测试 Firefox 专属开关的用例中临时改为 true + isFirefoxMock: vi.fn(() => false), +})); vi.mock("@App/pages/store/global", () => ({ systemConfig: { get, set }, subscribeMessage: () => () => {}, @@ -42,11 +49,13 @@ vi.mock("@App/pages/store/global", () => ({ // 后台权限检测在挂载时调用,固定返回 false 以免干扰存储配置测试 vi.mock("@App/pkg/utils/utils", async (orig) => { const actual = (await orig()) as Record; - return { ...actual, isPermissionOk: vi.fn(() => Promise.resolve(false)) }; + return { ...actual, isPermissionOk, isFirefox: isFirefoxMock }; }); import { RuntimeSection } from "./RuntimeSection"; +beforeAll(() => initTestLanguage("en-US")); + function mockStorage(over: Record = {}) { get.mockImplementation((key: string) => { if (key === "cat_file_storage") @@ -59,12 +68,63 @@ afterEach(() => { cleanup(); get.mockReset(); set.mockReset(); + isPermissionOk.mockReset(); + isPermissionOk.mockImplementation((permission: string) => + Promise.resolve(permission === "webRequestBlocking" ? null : false) + ); + isFirefoxMock.mockReset(); + isFirefoxMock.mockReturnValue(false); create.mockReset(); create.mockResolvedValue({ openDir: vi.fn(() => Promise.resolve({ getDirUrl: vi.fn(() => Promise.resolve("https://dir")) })), }); }); +describe("运行时分区-可选保活权限", () => { + it("非 Firefox 浏览器显示 Chrome 保活开关而不依赖 webRequestBlocking", async () => { + mockStorage(); + render( () => {}} />); + await screen.findByTestId("cat_storage_save"); + expect(screen.getByText("Keep Background and Scheduled Scripts Alive")).toBeInTheDocument(); + expect(screen.getAllByRole("switch")).toHaveLength(2); + }); + + it("非 Firefox 浏览器切换保活开关时保存配置", async () => { + mockStorage(); + render( () => {}} />); + await screen.findByText("Keep Background and Scheduled Scripts Alive"); + + fireEvent.click(screen.getAllByRole("switch")[1]); + + expect(set).toHaveBeenCalledWith("keep_ext_background_alive", true); + }); + + it("manifest 包含 webRequestBlocking 时显示开关并可请求权限", async () => { + isPermissionOk.mockImplementation((permission: string) => + Promise.resolve(permission === "webRequestBlocking" ? false : false) + ); + // isFirefox 判断结果在模块顶层被固化为常量,临时开启后需重置模块并重新导入才能生效 + isFirefoxMock.mockReturnValue(true); + vi.resetModules(); + const { RuntimeSection: RuntimeSectionOnFirefox } = await import("./RuntimeSection.js"); + try { + const request = vi.spyOn(chrome.permissions, "request"); + mockStorage(); + render( () => {}} />); + await screen.findByText("Keep Background and Scheduled Scripts Alive"); + const toggle = screen.getAllByRole("switch").at(-1); + expect(toggle).toBeInTheDocument(); + + fireEvent.click(toggle!); + + expect(request).toHaveBeenCalledWith({ permissions: ["webRequestBlocking"] }, expect.any(Function)); + } finally { + isFirefoxMock.mockReturnValue(false); + vi.resetModules(); + } + }); +}); + describe("运行时分区-存储配置", () => { it("保存存储配置时校验账号成功后写入 success 状态", async () => { mockStorage({ status: "unset", filesystem: "webdav", params: { webdav: { url: "https://dav" } } }); diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index cac286201..88c9f35a0 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -13,16 +13,26 @@ import type { CATFileStorage } from "@App/pkg/config/config"; const STORAGE_EXAMPLE_URL = "https://github.com/scriptscat/scriptcat/blob/main/example/cat_file_storage.js"; +const boolFirefox = isFirefox(); + export function RuntimeSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { const { t } = useTranslation(); const [bg, setBg] = useState(false); + const [chromeKeepAlive, setChromeKeepAlive] = useState(false); + const [keepAlive, setKeepAlive] = useState(null); const [storage, setStorage] = useState(undefined); useEffect(() => { - if (!isFirefox()) { + if (!boolFirefox) { void isPermissionOk("background").then((r) => { if (r !== null) setBg(r); }); + void Promise.resolve(systemConfig.get("keep_ext_background_alive")).then((r) => setChromeKeepAlive(Boolean(r))); + } + if (boolFirefox) { + void isPermissionOk("webRequestBlocking").then((r) => { + setKeepAlive(r); + }); } void Promise.resolve(systemConfig.get("cat_file_storage")).then((v) => setStorage(v as CATFileStorage)); }, []); @@ -35,6 +45,9 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT return; } setBg(granted); + if (granted) { + notify.success(t("settings:enable_background.enable_success")!); + } }); } else { chrome.permissions.remove({ permissions: ["background"] }, (removed) => { @@ -44,6 +57,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT } if (removed) { setBg(false); + notify.success(t("settings:enable_background.disable_success")!); } else { void isPermissionOk("background").then((r) => { if (r !== null) setBg(r); @@ -53,6 +67,41 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT } }; + const toggleChromeKeepAlive = (enable: boolean) => { + setChromeKeepAlive(enable); + systemConfig.set("keep_ext_background_alive", enable); + }; + + const toggleKeepAlive = (enable: boolean) => { + if (enable) { + chrome.permissions.request({ permissions: ["webRequestBlocking"] }, (granted) => { + if (chrome.runtime.lastError) { + notify.error(t("settings:keep_scripts_alive.enable_failed")!); + return; + } + setKeepAlive(granted); + if (granted) { + notify.success(t("settings:keep_scripts_alive.enable_success")!); + } + }); + } else { + chrome.permissions.remove({ permissions: ["webRequestBlocking"] }, (removed) => { + if (chrome.runtime.lastError) { + notify.error(t("settings:keep_scripts_alive.disable_failed")!); + return; + } + if (removed) { + setKeepAlive(false); + notify.success(t("settings:keep_scripts_alive.disable_success")!); + } else { + void isPermissionOk("webRequestBlocking").then((r) => { + setKeepAlive(r); + }); + } + }); + } + }; + const storageStatusLabel = storage?.status === "success" ? t("editor:in_use") @@ -93,12 +142,29 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT return ( - {!isFirefox() && ( + {!boolFirefox && ( + <> + + + + + + + + )} + + {boolFirefox && keepAlive !== null && ( - + )} diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 0c89229f9..d6a12ecf5 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -376,6 +376,14 @@ export class SystemConfig { this._set("vscode_reconnect", val); } + public getKeepExtBackgroundAlive() { + return this._get("keep_ext_background_alive", false); + } + + public setKeepExtBackgroundAlive(val: boolean) { + this._set("keep_ext_background_alive", val); + } + defaultBackup(): Parameters[0] { return { filesystem: "webdav" as FileSystemType, diff --git a/src/pkg/config/consts.ts b/src/pkg/config/consts.ts index 4262081fc..ba294b835 100644 --- a/src/pkg/config/consts.ts +++ b/src/pkg/config/consts.ts @@ -10,4 +10,5 @@ export const STORAGE_LOCAL_KEYS: Set = new Set([ "check_update", // 扩展更新通知及已读状态(各设备已读状态独立) "enable_script", // 全局脚本开关(设备独立) "enable_script_incognito", // 隐身模式开关(浏览器级别) + "keep_ext_background_alive", // Chrome service worker 和 offscreen 保活开关 ]); diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index ada9fb720..e33f6429d 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -325,17 +325,17 @@ export function getBrowserType() { } export const isPermissionOk = async ( - manifestPermission: chrome.runtime.ManifestOptionalPermissions & chrome.runtime.ManifestPermissions + manifestPermission: chrome.runtime.ManifestOptionalPermissions | chrome.runtime.ManifestPermissions ): Promise => { // 兼容 Firefox - 避免因为检查 permission 时,该permission不存在于 optional permission 而报错 const manifest = chrome.runtime.getManifest(); - if (manifest.optional_permissions?.includes(manifestPermission)) { + if ((manifest.optional_permissions as readonly string[] | undefined)?.includes(manifestPermission)) { try { return await chrome.permissions.contains({ permissions: [manifestPermission] }); } catch { // ignored } - } else if (manifest.permissions?.includes(manifestPermission)) { + } else if ((manifest.permissions as readonly string[] | undefined)?.includes(manifestPermission)) { // mainfest 而列明有该permission, 不用检查 return true; } diff --git a/src/service_worker.ts b/src/service_worker.ts index aec4d1ae1..a2e76829f 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -6,9 +6,12 @@ import { ExtensionMessage } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; import { MessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerMessageSend } from "@Packages/message/window_message"; -import { EventPageOffscreenManager } from "./app/service/offscreen/event_page_manager"; +import { EventPageOffscreenManager, InProcessMessage } from "./app/service/offscreen/event_page_manager"; import migrate, { migrateChromeStorage } from "./app/migrate"; import { cleanInvalidKeys } from "./app/repo/resource"; +import { onServiceWorkerStarted } from "./app/service/offscreen/keep_alive"; + +onServiceWorkerStarted(self as unknown as ServiceWorkerGlobalScope); migrate(); migrateChromeStorage(); @@ -71,26 +74,36 @@ function main() { }); loggerCore.logger().debug("service worker start"); const swMessage = new ServiceWorkerMessageSend(); - // 同时接收ExtensionMessage(chrome.runtime)和ServiceWorkerMessageSend(postMessage)的消息 - const server = new Server("serviceWorker", [message, swMessage]); const messageQueue = new MessageQueue(); const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function"; - // Chrome needs a real offscreen document. Firefox MV3 uses EventPageOffscreenManager instead. if (hasOffscreenDocument) { const offscreen = new ServiceWorkerMessageSend(); + // 同时接收ExtensionMessage(chrome.runtime)和ServiceWorkerMessageSend(postMessage)的消息 + const server = new Server("serviceWorker", [message, swMessage]); const manager = new ServiceWorkerManager(server, messageQueue, offscreen); manager.initManager(); setupOffscreenDocument(); - } else { - const offscreen = new EventPageOffscreenManager(message); + } + // Chrome needs a real offscreen document. Firefox MV3 uses EventPageOffscreenManager instead. + else { + // Firefox MV3: the event page itself is the DOM-capable offscreen environment and runs in the + // SAME script/process as this service worker code - they are not separate contexts. Sending + // offscreen -> SW messages through chrome.runtime.sendMessage/connect (as Chrome's real + // offscreen document must) fails here with "Could not establish connection. Receiving end + // does not exist.", because a script cannot reach itself through that channel. `offscreenToSw` + // is an in-process bridge (mirrors the SW -> offscreen bridge already built into + // EventPageOffscreenManager) added as an extra receiver on the "serviceWorker" Server, and + // passed to EventPageOffscreenManager in place of the real chrome.runtime channel so every + // offscreen -> SW call (sendMessageToServiceWorker, getExtensionEnv, preparationOffscreen, + // forwarded GM API calls) stays in-process instead of round-tripping through chrome.runtime. + const offscreenToSw = new InProcessMessage(); + const server = new Server("serviceWorker", [message, swMessage, offscreenToSw]); + // 同一个 messageQueue 实例同时用于 SW 和 offscreen 两个角色:见 BackgroundEnvManagerBase + // 构造函数中 messageQueue 参数的说明 - chrome.runtime.sendMessage 广播不会送达发送方自己 + // 所在的 frame,各自新建 MessageQueue 会导致 mq.publish() 广播互相收不到。 + const offscreen = new EventPageOffscreenManager(offscreenToSw, messageQueue); const manager = new ServiceWorkerManager(server, messageQueue, offscreen); manager.initManager(); - // ServiceWorkerManager installs its preparationOffscreen subscribers after .initManager(). - // In Firefox MV3 there is no real offscreen document, so the background event page - // itself is already the DOM-capable offscreen environment. - setTimeout(() => { - messageQueue.emit("preparationOffscreen", {}); - }, 0); } }