From 7b371a8375451e84e64cf65135d89565772de6ed Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:31:37 +0900 Subject: [PATCH 01/31] Firefox MV3 Sandbox Manifest --- docs/architecture.md | 15 ++- packages/message/window_message.test.ts | 96 +++++++++++++++ packages/message/window_message.ts | 34 ++++-- src/app/service/offscreen/base.test.ts | 111 ++++++++++++++++++ src/app/service/offscreen/base.ts | 55 ++++++++- src/app/service/offscreen/client.ts | 8 ++ .../offscreen/event_page_manager.test.ts | 48 ++++++++ .../service/offscreen/event_page_manager.ts | 27 +++-- src/app/service/offscreen/index.ts | 15 ++- src/app/service/sandbox/index.test.ts | 74 ++++++++++++ src/app/service/sandbox/index.ts | 32 ++++- src/sandbox.ts | 2 + src/service_worker.ts | 26 ++-- 13 files changed, 511 insertions(+), 32 deletions(-) create mode 100644 src/app/service/offscreen/base.test.ts create mode 100644 src/app/service/offscreen/event_page_manager.test.ts create mode 100644 src/app/service/sandbox/index.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index cbe6f5f7a..983b9806e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,8 +119,19 @@ 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. Services never see this difference: they receive an `IOffscreenSend` and call `.init()`. diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 358e7f33f..49c368204 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -197,6 +197,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..8228c171d 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -36,17 +36,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 +118,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 +147,7 @@ export class WindowMessage implements Message { resolve!(body.data as T); resolve = null; // 设为 null 提醒JS引擎可以GC }); - this.target.postMessage(body, "*"); + this.getTarget().postMessage(body, "*"); }); } } 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..426f66e83 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -1,7 +1,8 @@ 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"; @@ -10,11 +11,22 @@ import { MessageQueue } 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"; + +// 兜底超时: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, @@ -22,7 +34,7 @@ export class BackgroundEnvManagerBase { private readonly serviceWorker: ServiceWorkerClient ) {} - logger(data: Logger) { + logger(data: LoggerRecord) { // 发送日志消息 this.sendMessageToServiceWorker({ action: "logger", @@ -31,10 +43,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 +99,10 @@ 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.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..a40841422 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -8,6 +8,14 @@ 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 }); } 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..9c8264834 --- /dev/null +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import "@Packages/chrome-extension-mock"; +import { Server } from "@Packages/message/server"; +import { EventPageOffscreenManager, InProcessMessage } from "./event_page_manager"; + +initTestEnv(); + +// 单测重点: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); + + 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); + + // 触发 sandbox 就绪通知:会走 notifyOffscreenReady -> this.serviceWorker.preparationOffscreen() + manager.preparationSandbox(); + + // preparationOffscreen() 内部走 sendMessage,是异步的;等待其对应的微任务/宏任务跑完 + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(serviceWorkerHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index eff5f8dcb..0fb6da6bf 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -48,7 +48,12 @@ class InProcessMessageConnect implements MessageConnect { } } -class InProcessMessage implements Message, MessageSend { +// 同一脚本内的进程内消息桥接:Firefox MV3 下事件页本身兼任 offscreen 角色,与 SW 是同一个 +// 脚本/进程,彼此之间不能通过 chrome.runtime.sendMessage/connect 通讯——自己发给自己会报 +// "Could not establish connection. Receiving end does not exist."。导出给 service_worker.ts +// 用来搭建 offscreen -> SW 方向的桥接(SW -> offscreen 方向见本文件下方 EventPageOffscreenManager +// 内部已有的同名用法)。 +export class InProcessMessage implements Message, MessageSend { private events = new EventEmitter(); connect(data: TMessage): Promise { @@ -89,18 +94,24 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen } const sandbox = document.createElement("iframe"); - sandbox.src = chrome.runtime.getURL("src/sandbox.html"); + sandbox.src = chrome.runtime.getURL("/src/sandbox.html"); sandbox.style.display = "none"; document.documentElement.appendChild(sandbox); - const target = sandbox.contentWindow; - if (!target) { - throw new Error("EventPageOffscreenManager failed to create sandbox iframe."); - } - const message = new InProcessMessage(); - const windowMessage = new WindowMessage(window, target); + // 不要缓存 sandbox.contentWindow 的快照:刚创建的 iframe 此刻仍是初始的 about:blank 文档, + // 之后才会导航到真正的 sandbox 页面(manifest sandbox 页在 Firefox 154+ 下是跨源 iframe)。 + // 缓存的 WindowProxy 引用在跨源导航后是否仍与消息事件的 e.source 全等属于浏览器实现细节, + // 不应依赖;因此传入惰性求值函数,每次发送/比对都重新读取 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); 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/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/sandbox.ts b/src/sandbox.ts index 5ed78ed69..673af12f1 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -4,6 +4,7 @@ import MessageWriter from "./app/logger/message_writer"; import { SandboxManager } from "./app/service/sandbox"; function main() { + window.parent.postMessage({ type: "sandbox-ready", msg: "0001" }, "*"); // 建立与offscreen页面的连接 const windowMessage: WindowMessage = new WindowMessage(window, parent); @@ -14,6 +15,7 @@ function main() { }); loggerCore.logger().debug("offscreen start"); + window.parent.postMessage({ type: "sandbox-ready", msg: "0002" }, "*"); // 初始化管理器 const manager = new SandboxManager(windowMessage); manager.initManager(); diff --git a/src/service_worker.ts b/src/service_worker.ts index aec4d1ae1..94b88df10 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -6,7 +6,7 @@ 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"; @@ -71,26 +71,32 @@ 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) { + // 同时接收ExtensionMessage(chrome.runtime)和ServiceWorkerMessageSend(postMessage)的消息 + const server = new Server("serviceWorker", [message, swMessage]); const offscreen = new ServiceWorkerMessageSend(); const manager = new ServiceWorkerManager(server, messageQueue, offscreen); manager.initManager(); setupOffscreenDocument(); } else { - const offscreen = new EventPageOffscreenManager(message); + // 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]); + const offscreen = new EventPageOffscreenManager(offscreenToSw); 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); } } From c5924ab24ba9ce47358d0f556170b5b939170292 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:23:38 +0900 Subject: [PATCH 02/31] fix `runtime.sendMessage` for message broadcast (`.publish`) without target audiences --- packages/message/message_queue.test.ts | 24 ++++++++++++++++++++++++ packages/message/message_queue.ts | 25 ++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) 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 }); } // 只发布给当前环境 From 82dd1387af4242da76ea605707e55e870c244bad Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:30:33 +0900 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=94=A5=20=E7=A7=BB=E9=99=A4=20sandb?= =?UTF-8?q?ox=20=E5=B0=B1=E7=BB=AA=E6=8F=A1=E6=89=8B=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E7=9A=84=20postMessage=20=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 诊断 Firefox MV3 offscreen<->SW 握手问题时临时加入,问题已定位并修复,探针不再需要。 --- src/sandbox.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sandbox.ts b/src/sandbox.ts index 673af12f1..5ed78ed69 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -4,7 +4,6 @@ import MessageWriter from "./app/logger/message_writer"; import { SandboxManager } from "./app/service/sandbox"; function main() { - window.parent.postMessage({ type: "sandbox-ready", msg: "0001" }, "*"); // 建立与offscreen页面的连接 const windowMessage: WindowMessage = new WindowMessage(window, parent); @@ -15,7 +14,6 @@ function main() { }); loggerCore.logger().debug("offscreen start"); - window.parent.postMessage({ type: "sandbox-ready", msg: "0002" }, "*"); // 初始化管理器 const manager = new SandboxManager(windowMessage); manager.initManager(); From 6753f72b797f95e18b1a0e5f111647bf028d2dec Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:35:45 +0900 Subject: [PATCH 04/31] Update pack.js --- scripts/pack.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/pack.js b/scripts/pack.js index 4fe465fe1..dba399a7d 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -86,7 +86,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", // 没有必须传送至第三方的资料。安装转页没有记录用户何时何地安装了什么。 From bd97e1c17d7826984b7453e349ae6ef8ed3c3f2b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:36:13 +0900 Subject: [PATCH 05/31] =?UTF-8?q?ScriptCat=20MV3=20=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20Firefox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/pack.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/pack.js b/scripts/pack.js index dba399a7d..da69cb79e 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; // ============================================================================ From df21120b6dbf45ca2b8a78a22e5a92f056408de6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:41:47 +0900 Subject: [PATCH 06/31] Update pack.js --- scripts/pack.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/pack.js b/scripts/pack.js index da69cb79e..01cc5f21d 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -75,7 +75,6 @@ delete chromeManifest.background.scripts; // Firefox MV3 不支持 "background" permission firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); delete firefoxManifest.background.service_worker; -delete firefoxManifest.sandbox; // Firefox 的扩展消息默认即为 structured clone,该键仅 Chromium 148+ 识别 delete firefoxManifest.message_serialization; firefoxManifest.browser_specific_settings = { From c67cae396898379f57b069d2c4aba46cafd99f13 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:51:29 +0900 Subject: [PATCH 07/31] `content_security_policy` fix --- rspack.config.ts | 8 +++++--- scripts/pack.js | 4 ++++ src/manifest.json | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index 55470a397..2b66846f6 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -155,9 +155,11 @@ export default { manifest.name = "__MSG_scriptcat_beta__"; } if (isReactTools) { - manifest.content_security_policy = { - extension_pages: "script-src 'self' http://localhost:8097; object-src 'self'", - }; + if (!manifest.content_security_policy) { + manifest.content_security_policy = {}; + } + manifest.content_security_policy.extension_pages = + "script-src 'self' http://localhost:8097; object-src 'self'"; } return JSON.stringify(manifest); }, diff --git a/scripts/pack.js b/scripts/pack.js index 01cc5f21d..0f4a39c84 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -71,6 +71,10 @@ const chromeManifest = applyAgentManifest({ ...manifest, background: { ...manife 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; +} // Firefox MV3 不支持 "background" permission firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); 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"], From 8857adc5a9b835ee822859977afc748c419475e4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:55:16 +0900 Subject: [PATCH 08/31] Update pack.js --- scripts/pack.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/pack.js b/scripts/pack.js index 0f4a39c84..9abcb9949 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -66,8 +66,22 @@ execSync("pnpm run build", { // 处理firefox和chrome的zip压缩包 // 浅拷贝防止后续修改 -const firefoxManifest = applyAgentManifest({ ...manifest, background: { ...manifest.background } }, agentEnabled); -const chromeManifest = applyAgentManifest({ ...manifest, background: { ...manifest.background } }, agentEnabled); +const firefoxManifest = applyAgentManifest( + { + ...manifest, + background: { ...manifest.background }, + content_security_policy: { ...manifest.content_security_policy }, + }, + agentEnabled +); +const chromeManifest = applyAgentManifest( + { + ...manifest, + background: { ...manifest.background }, + content_security_policy: { ...manifest.content_security_policy }, + }, + agentEnabled +); chromeManifest.optional_permissions = chromeManifest.optional_permissions.filter((val) => val !== "userScripts"); delete chromeManifest.background.scripts; From 44c4db5ffca5a8bf266dd1a82d3aad687ae17f27 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:56:56 +0900 Subject: [PATCH 09/31] Update pack.js --- scripts/pack.js | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/scripts/pack.js b/scripts/pack.js index 9abcb9949..074a4b671 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -66,22 +66,17 @@ execSync("pnpm run build", { // 处理firefox和chrome的zip压缩包 // 浅拷贝防止后续修改 -const firefoxManifest = applyAgentManifest( - { - ...manifest, - background: { ...manifest.background }, - content_security_policy: { ...manifest.content_security_policy }, - }, - agentEnabled -); -const chromeManifest = applyAgentManifest( - { - ...manifest, - background: { ...manifest.background }, - content_security_policy: { ...manifest.content_security_policy }, - }, - 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; From f4015e0716c819f4747beb422ed6d7ba62cac032 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:05:45 +0900 Subject: [PATCH 10/31] Firefox - incognito "split" is unsupported --- scripts/pack.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/pack.js b/scripts/pack.js index 074a4b671..91acbb95a 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -88,6 +88,10 @@ if (chromeManifest.content_security_policy && Object.keys(chromeManifest.content // Firefox MV3 不支持 "background" permission firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); delete firefoxManifest.background.service_worker; + +// Firefox - incognito "split" is unsupported +delete firefoxManifest.incognito; + // Firefox 的扩展消息默认即为 structured clone,该键仅 Chromium 148+ 识别 delete firefoxManifest.message_serialization; firefoxManifest.browser_specific_settings = { From e3bb0886805e733d94be983a67aa6f61f41e4133 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:12:04 +0900 Subject: [PATCH 11/31] minor Firefox MV3 manifest fix --- scripts/pack.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/pack.js b/scripts/pack.js index 91acbb95a..c7696f135 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -85,12 +85,19 @@ if (chromeManifest.content_security_policy && Object.keys(chromeManifest.content 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" +); + // Firefox MV3 不支持 "background" permission firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); delete firefoxManifest.background.service_worker; -// Firefox - incognito "split" is unsupported -delete firefoxManifest.incognito; +// 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; From b6329d4db3fcc8bce507f94e8a2eb9e3cc6854d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:24:46 +0900 Subject: [PATCH 12/31] fix event page messaging --- src/app/service/offscreen/base.ts | 17 ++++++-- .../offscreen/event_page_manager.test.ts | 43 ++++++++++++++++++- .../service/offscreen/event_page_manager.ts | 12 +++++- src/service_worker.ts | 5 ++- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index 426f66e83..e96e0b246 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -7,7 +7,7 @@ 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"; @@ -19,8 +19,6 @@ 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 握手触发,也可能由兜底超时触发, @@ -31,7 +29,18 @@ export class BackgroundEnvManagerBase { 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: LoggerRecord) { diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts index 9c8264834..6c861e3da 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { initTestEnv } from "@Tests/utils"; import "@Packages/chrome-extension-mock"; import { Server } from "@Packages/message/server"; +import { MessageQueue } from "@Packages/message/message_queue"; import { EventPageOffscreenManager, InProcessMessage } from "./event_page_manager"; initTestEnv(); @@ -20,7 +21,7 @@ describe("EventPageOffscreenManager <-> serviceWorker 进程内桥接", () => { const serviceWorkerServer = new Server("serviceWorker", bridge); serviceWorkerServer.on("getExtensionEnv", serviceWorkerHandler); - const manager = new EventPageOffscreenManager(bridge); + const manager = new EventPageOffscreenManager(bridge, new MessageQueue()); const result = await manager.getExtensionEnv({ requireUAD: false }); @@ -35,7 +36,7 @@ describe("EventPageOffscreenManager <-> serviceWorker 进程内桥接", () => { const serviceWorkerServer = new Server("serviceWorker", bridge); serviceWorkerServer.on("preparationOffscreen", serviceWorkerHandler); - const manager = new EventPageOffscreenManager(bridge); + const manager = new EventPageOffscreenManager(bridge, new MessageQueue()); // 触发 sandbox 就绪通知:会走 notifyOffscreenReady -> this.serviceWorker.preparationOffscreen() manager.preparationSandbox(); @@ -46,3 +47,41 @@ describe("EventPageOffscreenManager <-> serviceWorker 进程内桥接", () => { 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 }]); + }); +}); diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index 0fb6da6bf..53ca8e04e 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -9,6 +9,7 @@ import type { } from "@Packages/message/types"; import { WindowMessage } from "@Packages/message/window_message"; import EventEmitter from "eventemitter3"; +import { type IMessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerClient } from "../service_worker/client"; import { BackgroundEnvManagerBase } from "./base"; @@ -88,7 +89,14 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen private readonly message: InProcessMessage; private initialized = false; - constructor(extMsgSender: MessageSend) { + constructor( + extMsgSender: MessageSend, + // 与 SW 是同一个脚本/进程时必须共用同一个 MessageQueue 实例:chrome.runtime.sendMessage 广播 + // 不会送达发送方自己所在的 frame,两边各自新建 MessageQueue 会导致互相收不到广播 + // (enableScripts/deleteScripts/installScript/setSandboxLanguage 全部失效,crontab 定时脚本 + // 也因此从不会被自动调度)。见 BackgroundEnvManagerBase 构造函数中 messageQueue 参数的说明。 + messageQueue: IMessageQueue + ) { if (typeof document !== "object" || !document?.documentElement) { throw new Error("EventPageOffscreenManager requires a DOM-capable Firefox MV3 Event Page."); } @@ -115,7 +123,7 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen 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/service_worker.ts b/src/service_worker.ts index 94b88df10..f84620d9a 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -94,7 +94,10 @@ function main() { // 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]); - const offscreen = new EventPageOffscreenManager(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(); } From fc11e78c8f8c013e3197f9d5cffd739ee571c615 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:26:37 +0900 Subject: [PATCH 13/31] fix logger for disable --- src/app/service/service_worker/script.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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; }); } From 26ec55ece5797da6bde889a5178ce73c6408a7c7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:03:17 +0900 Subject: [PATCH 14/31] startFirefoxEventPageKeepAliveLoop --- rspack.config.ts | 1 + scripts/pack.js | 5 + .../service/offscreen/event_page_manager.ts | 121 ++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/rspack.config.ts b/rspack.config.ts index 2b66846f6..3c501b3a5 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,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": "'false'", }), new rspack.CopyRspackPlugin({ patterns: [ diff --git a/scripts/pack.js b/scripts/pack.js index c7696f135..d3a1eb262 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -92,6 +92,11 @@ firefoxManifest.permissions = firefoxManifest.permissions.filter( (val) => val !== "userScripts" && val !== "debugger" && val !== "offscreen" ); +if (process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true") { + // bindEventPageLifeCycleBlocker + firefoxManifest.permissions.push("webRequestBlocking"); +} + // Firefox MV3 不支持 "background" permission firefoxManifest.optional_permissions = firefoxManifest.optional_permissions.filter((val) => val !== "background"); delete firefoxManifest.background.service_worker; diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index 53ca8e04e..a67e08ac0 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -106,6 +106,8 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen sandbox.style.display = "none"; document.documentElement.appendChild(sandbox); + startFirefoxEventPageKeepAliveLoop(); + const message = new InProcessMessage(); // 不要缓存 sandbox.contentWindow 的快照:刚创建的 iframe 此刻仍是初始的 about:blank 文档, @@ -143,3 +145,122 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen return this.message.sendMessage(data); } } + +/** + * 让 Firefox MV3 事件页避免被判定为"空闲"而被浏览器回收(自动挂起/卸载)的实验性 workaround。 + * + * ## 为什么需要这个 + * + * Firefox MV3 没有 Chrome 那种独立、长驻的 offscreen 文档;`EventPageOffscreenManager` 让事件页 + * 自己兼任 offscreen 角色(见本文件顶部说明)。但事件页本身仍会被 Firefox 按"空闲"策略挂起/回收—— + * 一旦发生,托管在其中的 sandbox iframe 连同它内部所有基于 setTimeout 的状态(尤其是 + * `Runtime.crontabScript()` 为 `@crontab` 脚本创建的 CronJob 定时器,见 + * `src/app/service/sandbox/runtime.ts`)会随之整体销毁,且没有任何机制会在事件页苏醒后重新 + * 挂载这些定时器。这正是"手动运行脚本正常,但 `@crontab` 定时任务从不自动触发"这一类问题的 + * 典型成因之一。 + * + * ## 工作原理 + * + * 利用 Firefox 支持、但 Chrome MV3 已禁止的 `webRequestBlocking` 权限: + * 1. 注册一个 `chrome.webRequest.onBeforeRequest` 的 blocking 监听器,只拦截自己发出的、 + * 带有 `__network_delay` 查询参数、指向一个刻意编造的不存在域名的探测请求,用 + * `setTimeout` 把该请求"扣住"指定的毫秒数才放行——这段时间里请求处于浏览器认定的 + * "有未完成网络活动"状态。 + * 2. 用一个 `` 标签持续发起这类探测请求,每次探测结束(无论成功还是失败,反正编造的域名 + * 本来就无法真正连通)就立刻发起下一次,形成自我延续的心跳循环,让事件页在浏览器眼中 + * "一直很忙",从而不满足空闲挂起的条件。 + * + * ## 重要限制与前提 + * + * - **仅在 Firefox 生效,且需要 `webRequestBlocking` 权限。** 该权限只在 + * `scripts/pack.js`(构建正式 Firefox 安装包时)动态注入到 manifest 中,`pnpm dev`/ + * `pnpm build` 产出的 `dist/ext/manifest.json` 默认不包含它——本地用 `dist/ext` 加载临时 + * 扩展调试时,若未手动给 manifest 补上这个权限,下面的 blocking 监听器形同虚设,探测请求会 + * 立即失败而不会被真正"扣住",导致心跳循环在 `onKeepAliveProbeSettled` 里因耗时不足 + * `MIN_ROUND_TRIP_TO_CONTINUE_MS` 而提前自行终止(见下方注释),使整个 workaround 悄悄失效 + * 却不会报错。 + * - **这只是启发式手段,不是浏览器保证的持久化机制。** 是否真的能阻止 Firefox 的空闲判定, + * 取决于 Firefox 内部未公开的具体算法,无法从代码层面完全保证;因此默认关闭,需要显式设置 + * 环境变量 `SC_KEEP_EVENT_PAGE_ACTIVE=true` 才会启用,避免在未经充分验证前影响所有用户。 + */ +const startFirefoxEventPageKeepAliveLoop = + process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" + ? () => { + // 探测请求的目标延迟时长:webRequest 监听器会把匹配的请求扣住这么久才放行 + const DEFAULT_PROBE_DELAY_MS = 10_000; + const MAX_PROBE_DELAY_MS = 120_000; + // 若一次探测的实际耗时低于这个阈值,说明 blocking 监听器很可能没有真正扣住请求 + // (例如缺少 webRequestBlocking 权限、或请求提前失败),此时不再发起下一次探测, + // 避免在网络失败的情况下无意义地空转循环。 + const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; + + // 刻意编造一个不存在的域名作为探测目标:只需要浏览器认为"有一个进行中的网络请求", + // 并不需要它真的连通到任何地方。域名基于扩展自身的 URL 派生,确保不会撞到真实网站。 + const keepAliveProbeUrl = `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; + const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; + + // 拦截并延迟放行上面这个探测请求,使其在浏览器眼中长时间处于"进行中"状态。 + chrome.webRequest.onBeforeRequest.addListener( + (details) => { + const lastError = chrome.runtime.lastError; + + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRequest:", lastError); + } + + if (!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, MAX_PROBE_DELAY_MS)) + : DEFAULT_PROBE_DELAY_MS; + + return new Promise((resolve) => { + setTimeout(() => { + // 延迟结束,放行原始请求 + resolve({}); + }, delayMs); + }); + }, + { + urls: [`${keepAliveProbeOrigin}/*`], + types: ["xmlhttprequest", "image"], + }, + ["blocking"] + ); + + let probeStartedAt: number; + + // 一次探测请求结束(成功或失败都会触发)后的回调:判断这次探测是否被真正延迟过, + // 若是则说明 workaround 仍然有效,继续发起下一次探测,维持心跳循环。 + const onKeepAliveProbeSettled = function (this: HTMLImageElement) { + this.remove(); + if (Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + sendKeepAliveProbe(); + } as any; + + // 发起一次探测请求,作为心跳循环的一拍 + const sendKeepAliveProbe = () => { + const image = new Image(); + probeStartedAt = Date.now(); + + image.onload = onKeepAliveProbeSettled; + + image.onerror = onKeepAliveProbeSettled; + + image.src = `${keepAliveProbeUrl}?__network_delay=${DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; + document.documentElement.appendChild(image); + }; + + sendKeepAliveProbe(); + } + : () => {}; From 1f9123748656eed996aa368233d11ae86b2a9b17 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:53:05 +0900 Subject: [PATCH 15/31] startFirefoxEventPageKeepAliveLoop : change to nativeScheduler --- .../service/offscreen/event_page_manager.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index a67e08ac0..06c5b4dfa 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -146,6 +146,10 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen } } +const nativeScheduler = + //@ts-ignore + typeof scheduler !== "undefined" && typeof scheduler?.postTask === "function" && scheduler; + /** * 让 Firefox MV3 事件页避免被判定为"空闲"而被浏览器回收(自动挂起/卸载)的实验性 workaround。 * @@ -184,7 +188,7 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen * 环境变量 `SC_KEEP_EVENT_PAGE_ACTIVE=true` 才会启用,避免在未经充分验证前影响所有用户。 */ const startFirefoxEventPageKeepAliveLoop = - process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" + process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" && nativeScheduler ? () => { // 探测请求的目标延迟时长:webRequest 监听器会把匹配的请求扣住这么久才放行 const DEFAULT_PROBE_DELAY_MS = 10_000; @@ -225,10 +229,14 @@ const startFirefoxEventPageKeepAliveLoop = : DEFAULT_PROBE_DELAY_MS; return new Promise((resolve) => { - setTimeout(() => { - // 延迟结束,放行原始请求 - resolve({}); - }, delayMs); + nativeScheduler.postTask( + () => { + // 延迟结束,放行原始请求 + resolve({}); + }, + { priority: "user-visible", delay: delayMs } + ); + // user-visible: try hardest to service promptly regardless of focus state }); }, { From 5c626c6a64eeeaf3ab41d1c7b1adc7b4d50a57b9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:00:37 +0900 Subject: [PATCH 16/31] docs: clarify Firefox event page implementation notes --- .../service/offscreen/event_page_manager.ts | 87 +++++++------------ 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index 06c5b4dfa..c1c81fd18 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -49,11 +49,10 @@ class InProcessMessageConnect implements MessageConnect { } } -// 同一脚本内的进程内消息桥接:Firefox MV3 下事件页本身兼任 offscreen 角色,与 SW 是同一个 -// 脚本/进程,彼此之间不能通过 chrome.runtime.sendMessage/connect 通讯——自己发给自己会报 -// "Could not establish connection. Receiving end does not exist."。导出给 service_worker.ts -// 用来搭建 offscreen -> SW 方向的桥接(SW -> offscreen 方向见本文件下方 EventPageOffscreenManager -// 内部已有的同名用法)。 +// 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(); @@ -91,10 +90,9 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen constructor( extMsgSender: MessageSend, - // 与 SW 是同一个脚本/进程时必须共用同一个 MessageQueue 实例:chrome.runtime.sendMessage 广播 - // 不会送达发送方自己所在的 frame,两边各自新建 MessageQueue 会导致互相收不到广播 - // (enableScripts/deleteScripts/installScript/setSandboxLanguage 全部失效,crontab 定时脚本 - // 也因此从不会被自动调度)。见 BackgroundEnvManagerBase 构造函数中 messageQueue 参数的说明。 + // Firefox 的 SW 与 offscreen 共处同一 frame,runtime.sendMessage 广播不会回送给发送者; + // 因此必须注入 SW 已有的队列,让 publish() 的本地 EventEmitter 完成分发。 + // Chrome 的独立 offscreen 文档仍由 BackgroundEnvManagerBase 使用默认队列。 messageQueue: IMessageQueue ) { if (typeof document !== "object" || !document?.documentElement) { @@ -110,11 +108,8 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen const message = new InProcessMessage(); - // 不要缓存 sandbox.contentWindow 的快照:刚创建的 iframe 此刻仍是初始的 about:blank 文档, - // 之后才会导航到真正的 sandbox 页面(manifest sandbox 页在 Firefox 154+ 下是跨源 iframe)。 - // 缓存的 WindowProxy 引用在跨源导航后是否仍与消息事件的 e.source 全等属于浏览器实现细节, - // 不应依赖;因此传入惰性求值函数,每次发送/比对都重新读取 contentWindow,并在读取时校验非空 - // (覆盖 iframe 之后被移除等场景)。 + // iframe 创建后会从 about:blank 导航到跨源 sandbox 页面。惰性读取 contentWindow,避免 + // 在导航前固定目标引用,并在 iframe 被移除时给出明确错误。 const windowMessage = new WindowMessage(window, () => { const win = sandbox.contentWindow; if (!win) { @@ -146,64 +141,45 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen } } +// 保活实验依赖 Scheduler API:带 delay 的 user-visible task 相比 setTimeout 更不容易在后台降频。 +// API 不可用时直接关闭实验,避免引入另一套行为不一致的计时路径。 const nativeScheduler = //@ts-ignore typeof scheduler !== "undefined" && typeof scheduler?.postTask === "function" && scheduler; /** - * 让 Firefox MV3 事件页避免被判定为"空闲"而被浏览器回收(自动挂起/卸载)的实验性 workaround。 + * Firefox MV3 event page 保活实验(默认关闭)。 * - * ## 为什么需要这个 + * 背景:Firefox 没有 Chrome 的 offscreen document;event page 被挂起时,sandbox iframe 及其 + * 内存态定时任务会一并销毁,当前代码不会在恢复后重建这些 `@crontab` 调度。 * - * Firefox MV3 没有 Chrome 那种独立、长驻的 offscreen 文档;`EventPageOffscreenManager` 让事件页 - * 自己兼任 offscreen 角色(见本文件顶部说明)。但事件页本身仍会被 Firefox 按"空闲"策略挂起/回收—— - * 一旦发生,托管在其中的 sandbox iframe 连同它内部所有基于 setTimeout 的状态(尤其是 - * `Runtime.crontabScript()` 为 `@crontab` 脚本创建的 CronJob 定时器,见 - * `src/app/service/sandbox/runtime.ts`)会随之整体销毁,且没有任何机制会在事件页苏醒后重新 - * 挂载这些定时器。这正是"手动运行脚本正常,但 `@crontab` 定时任务从不自动触发"这一类问题的 - * 典型成因之一。 + * 机制:使用 `webRequestBlocking` 暂停一个扩展自身发起的探测请求,并用 + * `scheduler.postTask()` 延迟返回 blocking 响应;`` 在请求结束后继续下一轮。 + * 未完成网络请求可能阻止 event page 被判定为空闲。 * - * ## 工作原理 + * 启用条件: + * - 构建时设置 `SC_KEEP_EVENT_PAGE_ACTIVE=true`; + * - Firefox manifest 包含 `webRequestBlocking`(`scripts/pack.js` 仅在同一开关开启时注入); + * - 浏览器提供 `scheduler.postTask`。 * - * 利用 Firefox 支持、但 Chrome MV3 已禁止的 `webRequestBlocking` 权限: - * 1. 注册一个 `chrome.webRequest.onBeforeRequest` 的 blocking 监听器,只拦截自己发出的、 - * 带有 `__network_delay` 查询参数、指向一个刻意编造的不存在域名的探测请求,用 - * `setTimeout` 把该请求"扣住"指定的毫秒数才放行——这段时间里请求处于浏览器认定的 - * "有未完成网络活动"状态。 - * 2. 用一个 `` 标签持续发起这类探测请求,每次探测结束(无论成功还是失败,反正编造的域名 - * 本来就无法真正连通)就立刻发起下一次,形成自我延续的心跳循环,让事件页在浏览器眼中 - * "一直很忙",从而不满足空闲挂起的条件。 - * - * ## 重要限制与前提 - * - * - **仅在 Firefox 生效,且需要 `webRequestBlocking` 权限。** 该权限只在 - * `scripts/pack.js`(构建正式 Firefox 安装包时)动态注入到 manifest 中,`pnpm dev`/ - * `pnpm build` 产出的 `dist/ext/manifest.json` 默认不包含它——本地用 `dist/ext` 加载临时 - * 扩展调试时,若未手动给 manifest 补上这个权限,下面的 blocking 监听器形同虚设,探测请求会 - * 立即失败而不会被真正"扣住",导致心跳循环在 `onKeepAliveProbeSettled` 里因耗时不足 - * `MIN_ROUND_TRIP_TO_CONTINUE_MS` 而提前自行终止(见下方注释),使整个 workaround 悄悄失效 - * 却不会报错。 - * - **这只是启发式手段,不是浏览器保证的持久化机制。** 是否真的能阻止 Firefox 的空闲判定, - * 取决于 Firefox 内部未公开的具体算法,无法从代码层面完全保证;因此默认关闭,需要显式设置 - * 环境变量 `SC_KEEP_EVENT_PAGE_ACTIVE=true` 才会启用,避免在未经充分验证前影响所有用户。 + * 这不是 Firefox 保证的生命周期机制。任一条件不满足时函数为空操作;探测未被实际延迟时, + * `onKeepAliveProbeSettled` 会停止循环,避免快速重试。 */ const startFirefoxEventPageKeepAliveLoop = process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" && nativeScheduler ? () => { - // 探测请求的目标延迟时长:webRequest 监听器会把匹配的请求扣住这么久才放行 + // 期望每个 blocking request 保持未完成的时间。 const DEFAULT_PROBE_DELAY_MS = 10_000; const MAX_PROBE_DELAY_MS = 120_000; - // 若一次探测的实际耗时低于这个阈值,说明 blocking 监听器很可能没有真正扣住请求 - // (例如缺少 webRequestBlocking 权限、或请求提前失败),此时不再发起下一次探测, - // 避免在网络失败的情况下无意义地空转循环。 + // 实际往返过短说明 listener 未阻塞请求(通常是权限缺失或请求提前失败); + // 此时停止后续探测,避免失败请求形成紧密循环。 const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; - // 刻意编造一个不存在的域名作为探测目标:只需要浏览器认为"有一个进行中的网络请求", - // 并不需要它真的连通到任何地方。域名基于扩展自身的 URL 派生,确保不会撞到真实网站。 + // 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 const keepAliveProbeUrl = `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; - // 拦截并延迟放行上面这个探测请求,使其在浏览器眼中长时间处于"进行中"状态。 + // blocking listener 只处理带延迟标记的探测请求,并在 delayMs 后放行。 chrome.webRequest.onBeforeRequest.addListener( (details) => { const lastError = chrome.runtime.lastError; @@ -229,14 +205,14 @@ const startFirefoxEventPageKeepAliveLoop = : DEFAULT_PROBE_DELAY_MS; return new Promise((resolve) => { + // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 nativeScheduler.postTask( () => { - // 延迟结束,放行原始请求 + // 延迟完成后放行 blocking request。 resolve({}); }, { priority: "user-visible", delay: delayMs } ); - // user-visible: try hardest to service promptly regardless of focus state }); }, { @@ -248,8 +224,7 @@ const startFirefoxEventPageKeepAliveLoop = let probeStartedAt: number; - // 一次探测请求结束(成功或失败都会触发)后的回调:判断这次探测是否被真正延迟过, - // 若是则说明 workaround 仍然有效,继续发起下一次探测,维持心跳循环。 + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免权限缺失导致忙循环。 const onKeepAliveProbeSettled = function (this: HTMLImageElement) { this.remove(); if (Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; From 2db3d26d242eecacfae6d58525bada2f66ace209 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:05:10 +0900 Subject: [PATCH 17/31] =?UTF-8?q?`"process.env.SC=5FKEEP=5FEVENT=5FPAGE=5F?= =?UTF-8?q?ACTIVE":=20"'true'",=20//=20=E6=AD=A3=E5=BC=8F=20Release=20?= =?UTF-8?q?=E9=9C=80=E8=AE=BE=E4=B8=BA=20true=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E8=84=9A=E6=9C=AC=E5=A4=B1=E6=95=88`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rspack.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rspack.config.ts b/rspack.config.ts index 3c501b3a5..2d6908454 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,7 +138,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": "'false'", + "process.env.SC_KEEP_EVENT_PAGE_ACTIVE": "'true'", // 正式 Release 需设为 true 避免定时脚本失效 }), new rspack.CopyRspackPlugin({ patterns: [ From fe9aae7006c4b361387dcee7bc0449f145e78e0a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:15:34 +0900 Subject: [PATCH 18/31] update yaml --- .github/workflows/build.yaml | 1 + .github/workflows/packageRelease.yml | 1 + rspack.config.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) 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/rspack.config.ts b/rspack.config.ts index 2d6908454..aff8cac87 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 === "true"; // Target browsers, see: https://github.com/browserslist/browserslist // 依照 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/userScripts#browser_compatibility @@ -138,7 +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": "'true'", // 正式 Release 需设为 true 避免定时脚本失效 + "process.env.SC_KEEP_EVENT_PAGE_ACTIVE": `'${keepEventPageActive ? "true" : "false"}'`, // 正式 Release 需设为 true 避免定时脚本失效 }), new rspack.CopyRspackPlugin({ patterns: [ From 31c5257b0240ac3af6069ad1a8be33fd85c4914a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:19:58 +0900 Subject: [PATCH 19/31] fix comment --- scripts/pack.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/pack.js b/scripts/pack.js index d3a1eb262..79f052992 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -93,7 +93,7 @@ firefoxManifest.permissions = firefoxManifest.permissions.filter( ); if (process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true") { - // bindEventPageLifeCycleBlocker + // for startFirefoxEventPageKeepAliveLoop firefoxManifest.permissions.push("webRequestBlocking"); } From 67d1f83a9188f2040ef0b9dd5f72e9171d48a06f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:00:32 +0900 Subject: [PATCH 20/31] =?UTF-8?q?=E2=9C=A8=20make=20Firefox=20keep-alive?= =?UTF-8?q?=20permission=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 8 +- packages/chrome-extension-mock/index.ts | 2 + packages/chrome-extension-mock/permissions.ts | 84 +++++++++++-- .../chrome-extension-mock/web_reqeuest.ts | 32 ++++- scripts/pack.js | 4 +- .../offscreen/event_page_manager.test.ts | 89 +++++++++++++- .../service/offscreen/event_page_manager.ts | 112 +++++++++++------- src/locales/de-DE/settings.json | 11 ++ src/locales/en-US/settings.json | 11 ++ src/locales/ja-JP/settings.json | 11 ++ src/locales/ru-RU/settings.json | 11 ++ src/locales/tr-TR/settings.json | 11 ++ src/locales/vi-VN/settings.json | 11 ++ src/locales/zh-CN/settings.json | 11 ++ src/locales/zh-TW/settings.json | 11 ++ src/pages/install/App.tsx | 28 +++-- .../components/BackgroundPrompt.test.tsx | 13 +- .../install/components/BackgroundPrompt.tsx | 33 ++++-- .../Setting/sections/RuntimeSection.test.tsx | 42 ++++++- .../Setting/sections/RuntimeSection.tsx | 39 ++++++ src/pkg/utils/utils.ts | 6 +- 21 files changed, 500 insertions(+), 80 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 983b9806e..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(); } ``` @@ -133,6 +133,12 @@ already has DOM and plays the offscreen role directly. `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/scripts/pack.js b/scripts/pack.js index 79f052992..48fa75c5c 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -93,8 +93,8 @@ firefoxManifest.permissions = firefoxManifest.permissions.filter( ); if (process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true") { - // for startFirefoxEventPageKeepAliveLoop - firefoxManifest.permissions.push("webRequestBlocking"); + // for startFirefoxEventPageKeepAliveLoop — 运行时按需向用户申请 + firefoxManifest.optional_permissions.push("webRequestBlocking"); } // Firefox MV3 不支持 "background" permission diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts index 6c861e3da..fcdda96db 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -1,12 +1,41 @@ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { initTestEnv } from "@Tests/utils"; -import "@Packages/chrome-extension-mock"; +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() @@ -85,3 +114,59 @@ describe("EventPageOffscreenManager 与 SW 共用 MessageQueue", () => { 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("构造同步回合内先注册 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 c1c81fd18..900af10ec 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -159,7 +159,8 @@ const nativeScheduler = * * 启用条件: * - 构建时设置 `SC_KEEP_EVENT_PAGE_ACTIVE=true`; - * - Firefox manifest 包含 `webRequestBlocking`(`scripts/pack.js` 仅在同一开关开启时注入); + * - Firefox manifest 在同一构建开关下把 `webRequestBlocking` 注入 `optional_permissions`; + * - 用户通过安装提示或设置页授予 `webRequestBlocking`; * - 浏览器提供 `scheduler.postTask`。 * * 这不是 Firefox 保证的生命周期机制。任一条件不满足时函数为空操作;探测未被实际延迟时, @@ -168,6 +169,8 @@ const nativeScheduler = const startFirefoxEventPageKeepAliveLoop = process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" && nativeScheduler ? () => { + let running = false; + // 期望每个 blocking request 保持未完成的时间。 const DEFAULT_PROBE_DELAY_MS = 10_000; const MAX_PROBE_DELAY_MS = 120_000; @@ -179,49 +182,6 @@ const startFirefoxEventPageKeepAliveLoop = const keepAliveProbeUrl = `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; - // 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 (!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, MAX_PROBE_DELAY_MS)) - : DEFAULT_PROBE_DELAY_MS; - - return new Promise((resolve) => { - // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 - nativeScheduler.postTask( - () => { - // 延迟完成后放行 blocking request。 - resolve({}); - }, - { priority: "user-visible", delay: delayMs } - ); - }); - }, - { - urls: [`${keepAliveProbeOrigin}/*`], - types: ["xmlhttprequest", "image"], - }, - ["blocking"] - ); - let probeStartedAt: number; // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免权限缺失导致忙循环。 @@ -244,6 +204,68 @@ const startFirefoxEventPageKeepAliveLoop = document.documentElement.appendChild(image); }; - sendKeepAliveProbe(); + const startLoop = () => { + if (running) 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 (!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, MAX_PROBE_DELAY_MS)) + : DEFAULT_PROBE_DELAY_MS; + + return new Promise((resolve) => { + // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 + nativeScheduler.postTask( + () => { + // 延迟完成后放行 blocking request。 + resolve({}); + }, + { priority: "user-visible", delay: delayMs } + ); + }); + }, + { + urls: [`${keepAliveProbeOrigin}/*`], + types: ["xmlhttprequest", "image"], + }, + ["blocking"] + ); + + sendKeepAliveProbe(); + }; + + // 必须在事件页首个同步回合注册;撤销权限时依赖现有往返过短自停逻辑退出探测循环。 + 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(); + }); + + void chrome.permissions.contains({ permissions: ["webRequestBlocking"] }).then((granted) => { + if (granted) startLoop(); + }); } : () => {}; diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 466ef7e2e..367449827 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -102,6 +102,17 @@ "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 in Firefox die Hintergrundseite 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", + "disable_failed": "Deaktivierung fehlgeschlagen", + "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..2216f3839 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -102,6 +102,17 @@ "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": "On Firefox, keeps ScriptCat's background page 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", + "disable_failed": "Failed to disable", + "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..e7a8f2b1f 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -102,6 +102,17 @@ "maybe_later": "後で", "settings_hint": "設定ページでいつでも変更できます。" }, + "keep_scripts_alive": { + "title": "バックグラウンドスクリプトとスケジュールスクリプトを維持", + "description": "Firefox で ScriptCat のバックグラウンドページをアクティブに保ち、バックグラウンドスクリプトとスケジュールスクリプトの実行を続けます。この機能は実験的で、ブラウザーが保証する仕組みではなく、リソース使用量が少し増える場合があります。", + "enable_failed": "有効化に失敗しました", + "disable_failed": "無効化に失敗しました", + "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..7a95794de 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -102,6 +102,17 @@ "maybe_later": "Может быть позже", "settings_hint": "Вы можете изменить эту опцию в настройках в любое время." }, + "keep_scripts_alive": { + "title": "Поддерживать фоновые скрипты и скрипты по расписанию", + "description": "В Firefox поддерживает активность фоновой страницы ScriptCat, чтобы фоновые скрипты и скрипты по расписанию продолжали выполняться. Функция экспериментальная, не гарантируется браузером и может немного увеличить расход ресурсов.", + "enable_failed": "Не удалось включить", + "disable_failed": "Не удалось отключить", + "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..f681081a0 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -102,6 +102,17 @@ "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": "Firefox'ta ScriptCat'in arka plan sayfası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", + "disable_failed": "Devre Dışı Bırakılamadı", + "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..253b75308 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -102,6 +102,17 @@ "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": "Trên Firefox, giữ trang 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", + "disable_failed": "Tắt thất bại", + "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..6d29ea1fc 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -102,6 +102,17 @@ "maybe_later": "暂不启用", "settings_hint": "你可以随时在设置中修改此选项。" }, + "keep_scripts_alive": { + "title": "保持后台和定时脚本运行", + "description": "在 Firefox 上保持脚本猫的后台页面活跃,让后台脚本和定时脚本继续运行。此功能仍属实验性质,浏览器不保证一定有效,并可能略微增加资源占用。", + "enable_failed": "启用失败", + "disable_failed": "禁用失败", + "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..6055778b6 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -102,6 +102,17 @@ "maybe_later": "暫不啟用", "settings_hint": "你可以隨時在設定中修改此選項。" }, + "keep_scripts_alive": { + "title": "保持背景與定時腳本執行", + "description": "在 Firefox 上保持 ScriptCat 的背景頁面作用中,讓背景腳本與定時腳本繼續執行。此功能仍屬實驗性質,瀏覽器不保證一定有效,且可能略微增加資源用量。", + "enable_failed": "啟用失敗", + "disable_failed": "停用失敗", + "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/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..c712a6b1e 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,11 @@ 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 } = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + isPermissionOk: vi.fn((permission: string) => Promise.resolve(permission === "webRequestBlocking" ? null : false)), +})); vi.mock("@App/pages/store/global", () => ({ systemConfig: { get, set }, subscribeMessage: () => () => {}, @@ -42,11 +47,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 }; }); import { RuntimeSection } from "./RuntimeSection"; +beforeAll(() => initTestLanguage("en-US")); + function mockStorage(over: Record = {}) { get.mockImplementation((key: string) => { if (key === "cat_file_storage") @@ -59,12 +66,41 @@ afterEach(() => { cleanup(); get.mockReset(); set.mockReset(); + isPermissionOk.mockReset(); + isPermissionOk.mockImplementation((permission: string) => + Promise.resolve(permission === "webRequestBlocking" ? null : false) + ); create.mockReset(); create.mockResolvedValue({ openDir: vi.fn(() => Promise.resolve({ getDirUrl: vi.fn(() => Promise.resolve("https://dir")) })), }); }); +describe("运行时分区-可选保活权限", () => { + it("manifest 不包含 webRequestBlocking 时不显示 Firefox 保活开关", async () => { + mockStorage(); + render( () => {}} />); + await screen.findByTestId("cat_storage_save"); + expect(screen.queryByText("Keep Background and Scheduled Scripts Alive")).not.toBeInTheDocument(); + }); + + it("manifest 包含 webRequestBlocking 时显示开关并可请求权限", async () => { + isPermissionOk.mockImplementation((permission: string) => + Promise.resolve(permission === "webRequestBlocking" ? false : false) + ); + 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)); + }); +}); + 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..e3f253062 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -16,6 +16,7 @@ const STORAGE_EXAMPLE_URL = "https://github.com/scriptscat/scriptcat/blob/main/e export function RuntimeSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { const { t } = useTranslation(); const [bg, setBg] = useState(false); + const [keepAlive, setKeepAlive] = useState(null); const [storage, setStorage] = useState(undefined); useEffect(() => { @@ -24,6 +25,9 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT if (r !== null) setBg(r); }); } + void isPermissionOk("webRequestBlocking").then((r) => { + setKeepAlive(r); + }); void Promise.resolve(systemConfig.get("cat_file_storage")).then((v) => setStorage(v as CATFileStorage)); }, []); @@ -53,6 +57,32 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT } }; + 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); + }); + } 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); + } else { + void isPermissionOk("webRequestBlocking").then((r) => { + setKeepAlive(r); + }); + } + }); + } + }; + const storageStatusLabel = storage?.status === "success" ? t("editor:in_use") @@ -102,6 +132,15 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT )} + {keepAlive !== null && ( + + + + )} + {storage && (
{t("editor:storage_api")}
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; } From 1e5caf8603812f5868df679305089ef3e02bf310 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:53:39 +0900 Subject: [PATCH 21/31] Update RuntimeSection.tsx --- .../routes/Setting/sections/RuntimeSection.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index e3f253062..177e5f6be 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -13,6 +13,8 @@ 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); @@ -20,14 +22,16 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT const [storage, setStorage] = useState(undefined); useEffect(() => { - if (!isFirefox()) { + if (!boolFirefox) { void isPermissionOk("background").then((r) => { if (r !== null) setBg(r); }); } - void isPermissionOk("webRequestBlocking").then((r) => { - setKeepAlive(r); - }); + if (boolFirefox) { + void isPermissionOk("webRequestBlocking").then((r) => { + setKeepAlive(r); + }); + } void Promise.resolve(systemConfig.get("cat_file_storage")).then((v) => setStorage(v as CATFileStorage)); }, []); @@ -123,7 +127,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT return ( - {!isFirefox() && ( + {!boolFirefox && ( (el: HT )} - {keepAlive !== null && ( + {boolFirefox && keepAlive !== null && ( Date: Sun, 12 Jul 2026 04:00:28 +0900 Subject: [PATCH 22/31] add success message after toggle --- src/locales/de-DE/settings.json | 4 ++++ src/locales/en-US/settings.json | 4 ++++ src/locales/ja-JP/settings.json | 4 ++++ src/locales/ru-RU/settings.json | 4 ++++ src/locales/tr-TR/settings.json | 4 ++++ src/locales/vi-VN/settings.json | 4 ++++ src/locales/zh-CN/settings.json | 4 ++++ src/locales/zh-TW/settings.json | 4 ++++ .../options/routes/Setting/sections/RuntimeSection.tsx | 8 ++++++++ 9 files changed, 40 insertions(+) diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 367449827..36d2b7340 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -95,7 +95,9 @@ "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", @@ -106,7 +108,9 @@ "title": "Hintergrund- und geplante Skripte aktiv halten", "description": "Hält in Firefox die Hintergrundseite 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", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index 2216f3839..3c77538c6 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -95,7 +95,9 @@ "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", @@ -106,7 +108,9 @@ "title": "Keep Background and Scheduled Scripts Alive", "description": "On Firefox, keeps ScriptCat's background page 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", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index e7a8f2b1f..df6749ab6 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -95,7 +95,9 @@ "title": "バックグラウンド実行を有効にする", "description": "有効にすると、すべてのウィンドウを閉じた後もブラウザはバックグラウンドで動作し、手動で終了するまでトレイに最小化されます。これにより、バックグラウンドスクリプトの実行が続きます。", "enable_failed": "有効化に失敗しました", + "enable_success": "有効化しました", "disable_failed": "無効化に失敗しました", + "disable_success": "無効化しました", "prompt_title": "バックグラウンド実行を有効にしますか?", "prompt_description": "これは{{scriptType}}です。バックグラウンド実行を有効にすると、ブラウザを閉じた後もスクリプトの実行が続きます。", "enable_now": "今すぐ有効にする", @@ -106,7 +108,9 @@ "title": "バックグラウンドスクリプトとスケジュールスクリプトを維持", "description": "Firefox で ScriptCat のバックグラウンドページをアクティブに保ち、バックグラウンドスクリプトとスケジュールスクリプトの実行を続けます。この機能は実験的で、ブラウザーが保証する仕組みではなく、リソース使用量が少し増える場合があります。", "enable_failed": "有効化に失敗しました", + "enable_success": "有効化しました", "disable_failed": "無効化に失敗しました", + "disable_success": "無効化しました", "prompt_title": "Firefox でスクリプトを維持しますか?", "prompt_description": "これは{{scriptType}}です。ScriptCat が Firefox のバックグラウンドページをアクティブに保つことを許可すると、スクリプトはスケジュールどおりに実行を続けられます。この機能は実験的で、リソース使用量が少し増える場合があります。", "enable_now": "今すぐ有効にする", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 7a95794de..b35d44e73 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -95,7 +95,9 @@ "title": "Включить фоновый режим", "description": "Если включено, браузер продолжит работать в фоновом режиме после закрытия всех окон и будет свернут в трей, пока вы вручную не завершите его работу. Это позволяет фоновым скриптам продолжать выполняться.", "enable_failed": "Не удалось включить", + "enable_success": "Включено", "disable_failed": "Не удалось отключить", + "disable_success": "Отключено", "prompt_title": "Включить фоновое выполнение?", "prompt_description": "Это {{scriptType}}. Включение фонового выполнения позволит скрипту продолжать работать после закрытия браузера.", "enable_now": "Включить сейчас", @@ -106,7 +108,9 @@ "title": "Поддерживать фоновые скрипты и скрипты по расписанию", "description": "В Firefox поддерживает активность фоновой страницы ScriptCat, чтобы фоновые скрипты и скрипты по расписанию продолжали выполняться. Функция экспериментальная, не гарантируется браузером и может немного увеличить расход ресурсов.", "enable_failed": "Не удалось включить", + "enable_success": "Включено", "disable_failed": "Не удалось отключить", + "disable_success": "Отключено", "prompt_title": "Поддерживать скрипты в Firefox?", "prompt_description": "Это {{scriptType}}. Разрешите ScriptCat поддерживать активность фоновой страницы Firefox, чтобы скрипт продолжал выполняться по расписанию. Функция экспериментальная и может немного увеличить расход ресурсов.", "enable_now": "Включить сейчас", diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index f681081a0..d132f952c 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -95,7 +95,9 @@ "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", @@ -106,7 +108,9 @@ "title": "Arka Plan ve Zamanlanmış Betikleri Canlı Tut", "description": "Firefox'ta ScriptCat'in arka plan sayfası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", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 253b75308..3e17648d5 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -95,7 +95,9 @@ "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", @@ -106,7 +108,9 @@ "title": "Giữ script nền và script hẹn giờ hoạt động", "description": "Trên Firefox, giữ trang 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", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 6d29ea1fc..9d40647b4 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -95,7 +95,9 @@ "title": "启用后台运行", "description": "启用后,即使关闭所有窗口,浏览器仍会在后台运行,并最小化到系统托盘,直到您手动退出浏览器。这使后台脚本能够继续运行。", "enable_failed": "启用失败", + "enable_success": "启用成功", "disable_failed": "禁用失败", + "disable_success": "禁用成功", "prompt_title": "是否开启后台运行?", "prompt_description": "此脚本是{{scriptType}},启用后台运行功能可以让脚本在浏览器关闭后继续运行。", "enable_now": "立即启用", @@ -106,7 +108,9 @@ "title": "保持后台和定时脚本运行", "description": "在 Firefox 上保持脚本猫的后台页面活跃,让后台脚本和定时脚本继续运行。此功能仍属实验性质,浏览器不保证一定有效,并可能略微增加资源占用。", "enable_failed": "启用失败", + "enable_success": "启用成功", "disable_failed": "禁用失败", + "disable_success": "禁用成功", "prompt_title": "是否在 Firefox 上保持脚本运行?", "prompt_description": "此脚本是{{scriptType}}。允许脚本猫保持 Firefox 后台页面活跃后,脚本可继续按计划运行。此功能仍属实验性质,并可能略微增加资源占用。", "enable_now": "立即启用", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 6055778b6..1af65166d 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -95,7 +95,9 @@ "title": "啟用背景運行", "description": "啟用後,即使關閉所有視窗,瀏覽器仍會在背景執行,並最小化到系統匣中,直到您手動退出瀏覽器。這能讓背景腳本繼續運作。", "enable_failed": "啟用失敗", + "enable_success": "啟用成功", "disable_failed": "禁用失敗", + "disable_success": "禁用成功", "prompt_title": "是否開啟背景運行?", "prompt_description": "此腳本是{{scriptType}},啟用背景運行功能可以讓腳本在瀏覽器關閉後繼續運行。", "enable_now": "立即啟用", @@ -106,7 +108,9 @@ "title": "保持背景與定時腳本執行", "description": "在 Firefox 上保持 ScriptCat 的背景頁面作用中,讓背景腳本與定時腳本繼續執行。此功能仍屬實驗性質,瀏覽器不保證一定有效,且可能略微增加資源用量。", "enable_failed": "啟用失敗", + "enable_success": "啟用成功", "disable_failed": "停用失敗", + "disable_success": "停用成功", "prompt_title": "是否在 Firefox 上保持腳本執行?", "prompt_description": "此腳本是{{scriptType}}。允許 ScriptCat 保持 Firefox 背景頁面作用中後,腳本可繼續按排程執行。此功能仍屬實驗性質,且可能略微增加資源用量。", "enable_now": "立即啟用", diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index 177e5f6be..d1d62250e 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -43,6 +43,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) => { @@ -52,6 +55,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); @@ -69,6 +73,9 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT return; } setKeepAlive(granted); + if (granted) { + notify.success(t("settings:keep_scripts_alive.enable_success")!); + } }); } else { chrome.permissions.remove({ permissions: ["webRequestBlocking"] }, (removed) => { @@ -78,6 +85,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT } if (removed) { setKeepAlive(false); + notify.success(t("settings:keep_scripts_alive.disable_success")!); } else { void isPermissionOk("webRequestBlocking").then((r) => { setKeepAlive(r); From cd04c672c66b35e003c0b96886b9ebf4b5afef04 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:41:40 +0900 Subject: [PATCH 23/31] fix --- src/app/service/offscreen/event_page_manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index 900af10ec..dd8966698 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -193,7 +193,7 @@ const startFirefoxEventPageKeepAliveLoop = // 发起一次探测请求,作为心跳循环的一拍 const sendKeepAliveProbe = () => { - const image = new Image(); + const image = new Image(1, 1); probeStartedAt = Date.now(); image.onload = onKeepAliveProbeSettled; From 8d2ef84fdd981ce6a851627e8117f852b8bf5985 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:46:02 +0900 Subject: [PATCH 24/31] fix test --- .../Setting/sections/RuntimeSection.test.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx index c712a6b1e..5bfd692fa 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -34,10 +34,12 @@ vi.mock("@Packages/filesystem/auth", () => ({ ClearNetDiskToken: vi.fn(() => Promise.resolve()), })); -const { get, set, isPermissionOk } = vi.hoisted(() => ({ +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 }, @@ -47,7 +49,7 @@ vi.mock("@App/pages/store/global", () => ({ // 后台权限检测在挂载时调用,固定返回 false 以免干扰存储配置测试 vi.mock("@App/pkg/utils/utils", async (orig) => { const actual = (await orig()) as Record; - return { ...actual, isPermissionOk }; + return { ...actual, isPermissionOk, isFirefox: isFirefoxMock }; }); import { RuntimeSection } from "./RuntimeSection"; @@ -70,6 +72,8 @@ afterEach(() => { 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")) })), @@ -88,16 +92,25 @@ describe("运行时分区-可选保活权限", () => { isPermissionOk.mockImplementation((permission: string) => Promise.resolve(permission === "webRequestBlocking" ? false : false) ); - 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(); + // 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!); + fireEvent.click(toggle!); - expect(request).toHaveBeenCalledWith({ permissions: ["webRequestBlocking"] }, expect.any(Function)); + expect(request).toHaveBeenCalledWith({ permissions: ["webRequestBlocking"] }, expect.any(Function)); + } finally { + isFirefoxMock.mockReturnValue(false); + vi.resetModules(); + } }); }); From fe669541a084b0f2d2a5ef9f3751b3108f86c9f9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:13:16 +0900 Subject: [PATCH 25/31] =?UTF-8?q?=E2=9C=A8=20add=20Chrome=20keep-alive=20s?= =?UTF-8?q?etting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/settings.json | 2 +- src/locales/en-US/settings.json | 2 +- src/locales/ja-JP/settings.json | 2 +- src/locales/ru-RU/settings.json | 2 +- src/locales/tr-TR/settings.json | 2 +- src/locales/vi-VN/settings.json | 2 +- src/locales/zh-CN/settings.json | 2 +- src/locales/zh-TW/settings.json | 2 +- src/offscreen.ts | 9 +++++++ .../Setting/sections/RuntimeSection.test.tsx | 15 +++++++++-- .../Setting/sections/RuntimeSection.tsx | 27 ++++++++++++++----- src/pkg/config/config.ts | 8 ++++++ src/pkg/config/consts.ts | 1 + src/service_worker.ts | 10 +++++++ 14 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 36d2b7340..75d6ca79c 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "Hintergrund- und geplante Skripte aktiv halten", - "description": "Hält in Firefox die Hintergrundseite von ScriptCat aktiv, damit Hintergrundskripte und geplante Skripte weiterlaufen. Diese Funktion ist experimentell, wird vom Browser nicht garantiert und kann etwas mehr Ressourcen verwenden.", + "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", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index 3c77538c6..821f9d0e8 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "Keep Background and Scheduled Scripts Alive", - "description": "On Firefox, keeps ScriptCat's background page active so Background Scripts and Scheduled Scripts continue to run. This is experimental, not guaranteed by the browser, and may use slightly more resources.", + "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", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index df6749ab6..253298449 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "バックグラウンドスクリプトとスケジュールスクリプトを維持", - "description": "Firefox で ScriptCat のバックグラウンドページをアクティブに保ち、バックグラウンドスクリプトとスケジュールスクリプトの実行を続けます。この機能は実験的で、ブラウザーが保証する仕組みではなく、リソース使用量が少し増える場合があります。", + "description": "ScriptCat のバックグラウンド実行環境をアクティブに保ち、バックグラウンドスクリプトとスケジュールスクリプトの実行を続けます。この機能は実験的で、ブラウザーが保証する仕組みではなく、リソース使用量が少し増える場合があります。", "enable_failed": "有効化に失敗しました", "enable_success": "有効化しました", "disable_failed": "無効化に失敗しました", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index b35d44e73..ffee875e7 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "Поддерживать фоновые скрипты и скрипты по расписанию", - "description": "В Firefox поддерживает активность фоновой страницы ScriptCat, чтобы фоновые скрипты и скрипты по расписанию продолжали выполняться. Функция экспериментальная, не гарантируется браузером и может немного увеличить расход ресурсов.", + "description": "Поддерживает активность фоновой среды ScriptCat, чтобы фоновые скрипты и скрипты по расписанию продолжали выполняться. Функция экспериментальная, не гарантируется браузером и может немного увеличить расход ресурсов.", "enable_failed": "Не удалось включить", "enable_success": "Включено", "disable_failed": "Не удалось отключить", diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index d132f952c..3cff36b79 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "Arka Plan ve Zamanlanmış Betikleri Canlı Tut", - "description": "Firefox'ta ScriptCat'in arka plan sayfası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.", + "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ı", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 3e17648d5..65be15bb4 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "Giữ script nền và script hẹn giờ hoạt động", - "description": "Trên Firefox, giữ trang 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.", + "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", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 9d40647b4..8751ffa18 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "保持后台和定时脚本运行", - "description": "在 Firefox 上保持脚本猫的后台页面活跃,让后台脚本和定时脚本继续运行。此功能仍属实验性质,浏览器不保证一定有效,并可能略微增加资源占用。", + "description": "保持脚本猫的后台运行环境活跃,让后台脚本和定时脚本继续运行。此功能仍属实验性质,浏览器不保证一定有效,并可能略微增加资源占用。", "enable_failed": "启用失败", "enable_success": "启用成功", "disable_failed": "禁用失败", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 1af65166d..06ac8eaad 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -106,7 +106,7 @@ }, "keep_scripts_alive": { "title": "保持背景與定時腳本執行", - "description": "在 Firefox 上保持 ScriptCat 的背景頁面作用中,讓背景腳本與定時腳本繼續執行。此功能仍屬實驗性質,瀏覽器不保證一定有效,且可能略微增加資源用量。", + "description": "保持 ScriptCat 的背景執行環境作用中,讓背景腳本與定時腳本繼續執行。此功能仍屬實驗性質,瀏覽器不保證一定有效,且可能略微增加資源用量。", "enable_failed": "啟用失敗", "enable_success": "啟用成功", "disable_failed": "停用失敗", diff --git a/src/offscreen.ts b/src/offscreen.ts index 9cc1cb42c..70b61ceeb 100644 --- a/src/offscreen.ts +++ b/src/offscreen.ts @@ -2,6 +2,13 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { OffscreenManager } from "./app/service/offscreen"; import { ServiceWorkerClientMessage } from "@Packages/message/window_message"; +import { MessageQueue } from "@Packages/message/message_queue"; +import { SystemConfig } from "./pkg/config/config"; + +const startChromeOffscreenKeepAliveLoop = (enabled: boolean) => { + if (!enabled) return; + // Chrome offscreen 保活循环将在后续实现。 +}; function main() { // 通过postMessage与SW通信,支持结构化克隆(Blob等) @@ -15,6 +22,8 @@ function main() { // 初始化管理器 const manager = new OffscreenManager(swPostMessage); manager.initManager(); + const systemConfig = new SystemConfig(new MessageQueue()); + void systemConfig.getKeepChromeScriptsAlive().then(startChromeOffscreenKeepAliveLoop); } main(); diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx index 5bfd692fa..9cf409fb9 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -81,11 +81,22 @@ afterEach(() => { }); describe("运行时分区-可选保活权限", () => { - it("manifest 不包含 webRequestBlocking 时不显示 Firefox 保活开关", async () => { + it("非 Firefox 浏览器显示 Chrome 保活开关而不依赖 webRequestBlocking", async () => { mockStorage(); render( () => {}} />); await screen.findByTestId("cat_storage_save"); - expect(screen.queryByText("Keep Background and Scheduled Scripts Alive")).not.toBeInTheDocument(); + 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_chrome_scripts_alive", true); }); it("manifest 包含 webRequestBlocking 时显示开关并可请求权限", async () => { diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index d1d62250e..ee744b824 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -18,6 +18,7 @@ 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); @@ -26,6 +27,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT void isPermissionOk("background").then((r) => { if (r !== null) setBg(r); }); + void Promise.resolve(systemConfig.get("keep_chrome_scripts_alive")).then((r) => setChromeKeepAlive(Boolean(r))); } if (boolFirefox) { void isPermissionOk("webRequestBlocking").then((r) => { @@ -65,6 +67,11 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT } }; + const toggleChromeKeepAlive = (enable: boolean) => { + setChromeKeepAlive(enable); + systemConfig.set("keep_chrome_scripts_alive", enable); + }; + const toggleKeepAlive = (enable: boolean) => { if (enable) { chrome.permissions.request({ permissions: ["webRequestBlocking"] }, (granted) => { @@ -136,12 +143,20 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT return ( {!boolFirefox && ( - - - + <> + + + + + + + )} {boolFirefox && keepAlive !== null && ( diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 0c89229f9..9e449e8c0 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 getKeepChromeScriptsAlive() { + return this._get("keep_chrome_scripts_alive", false); + } + + public setKeepChromeScriptsAlive(val: boolean) { + this._set("keep_chrome_scripts_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..32fe81252 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_chrome_scripts_alive", // Chrome service worker 和 offscreen 保活开关 ]); diff --git a/src/service_worker.ts b/src/service_worker.ts index f84620d9a..60c5840a7 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -9,12 +9,18 @@ import { ServiceWorkerMessageSend } from "@Packages/message/window_message"; import { EventPageOffscreenManager, InProcessMessage } from "./app/service/offscreen/event_page_manager"; import migrate, { migrateChromeStorage } from "./app/migrate"; import { cleanInvalidKeys } from "./app/repo/resource"; +import { SystemConfig } from "./pkg/config/config"; migrate(); migrateChromeStorage(); const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html"; +const startChromeServiceWorkerKeepAliveLoop = (enabled: boolean) => { + if (!enabled) return; + // Chrome service worker 保活循环将在后续实现。 +}; + let creating: Promise | null | boolean = null; async function hasDocument() { @@ -73,6 +79,10 @@ function main() { const swMessage = new ServiceWorkerMessageSend(); const messageQueue = new MessageQueue(); const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function"; + if (hasOffscreenDocument) { + const systemConfig = new SystemConfig(messageQueue); + void systemConfig.getKeepChromeScriptsAlive().then(startChromeServiceWorkerKeepAliveLoop); + } // Chrome needs a real offscreen document. Firefox MV3 uses EventPageOffscreenManager instead. if (hasOffscreenDocument) { // 同时接收ExtensionMessage(chrome.runtime)和ServiceWorkerMessageSend(postMessage)的消息 From fdfb04ed3f7d8f5910b7d181d3071c095cb7bbf7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:47:18 +0900 Subject: [PATCH 26/31] =?UTF-8?q?=E2=9C=A8=20add=20Chrome=20keep-alive=20s?= =?UTF-8?q?etting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/window_message.test.ts | 20 ++ packages/message/window_message.ts | 26 +- src/app/service/offscreen/base.ts | 2 + src/app/service/offscreen/client.ts | 4 + .../offscreen/event_page_manager.test.ts | 25 ++ .../service/offscreen/event_page_manager.ts | 130 +------ src/app/service/offscreen/keep_alive.ts | 325 ++++++++++++++++++ src/offscreen.ts | 9 - src/service_worker.ts | 20 +- 9 files changed, 407 insertions(+), 154 deletions(-) create mode 100644 src/app/service/offscreen/keep_alive.ts diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 49c368204..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", () => { diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 8228c171d..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, @@ -336,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; @@ -344,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; @@ -358,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}`); @@ -369,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/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts index e96e0b246..a88d3333c 100644 --- a/src/app/service/offscreen/base.ts +++ b/src/app/service/offscreen/base.ts @@ -12,6 +12,7 @@ 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 就绪上,超时后仍放行,但打印明确的错误日志 @@ -111,6 +112,7 @@ export class BackgroundEnvManagerBase { 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"), diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index a40841422..7e81aea8c 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -20,6 +20,10 @@ 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 index fcdda96db..93a9fab81 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -156,6 +156,31 @@ describe("EventPageOffscreenManager Firefox event page 保活权限门控", () = 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); diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index dd8966698..f398d2ec1 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -12,6 +12,7 @@ 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(); @@ -140,132 +141,3 @@ export class EventPageOffscreenManager extends BackgroundEnvManagerBase implemen return this.message.sendMessage(data); } } - -// 保活实验依赖 Scheduler API:带 delay 的 user-visible task 相比 setTimeout 更不容易在后台降频。 -// 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 startFirefoxEventPageKeepAliveLoop = - process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" && nativeScheduler - ? () => { - let running = false; - - // 期望每个 blocking request 保持未完成的时间。 - const DEFAULT_PROBE_DELAY_MS = 10_000; - const MAX_PROBE_DELAY_MS = 120_000; - // 实际往返过短说明 listener 未阻塞请求(通常是权限缺失或请求提前失败); - // 此时停止后续探测,避免失败请求形成紧密循环。 - const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; - - // 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 - const keepAliveProbeUrl = `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; - const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; - - let probeStartedAt: number; - - // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免权限缺失导致忙循环。 - const onKeepAliveProbeSettled = function (this: HTMLImageElement) { - this.remove(); - if (Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; - sendKeepAliveProbe(); - } as any; - - // 发起一次探测请求,作为心跳循环的一拍 - const sendKeepAliveProbe = () => { - const image = new Image(1, 1); - probeStartedAt = Date.now(); - - image.onload = onKeepAliveProbeSettled; - - image.onerror = onKeepAliveProbeSettled; - - image.src = `${keepAliveProbeUrl}?__network_delay=${DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; - document.documentElement.appendChild(image); - }; - - const startLoop = () => { - if (running) 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 (!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, MAX_PROBE_DELAY_MS)) - : DEFAULT_PROBE_DELAY_MS; - - return new Promise((resolve) => { - // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 - nativeScheduler.postTask( - () => { - // 延迟完成后放行 blocking request。 - resolve({}); - }, - { priority: "user-visible", delay: delayMs } - ); - }); - }, - { - urls: [`${keepAliveProbeOrigin}/*`], - types: ["xmlhttprequest", "image"], - }, - ["blocking"] - ); - - sendKeepAliveProbe(); - }; - - // 必须在事件页首个同步回合注册;撤销权限时依赖现有往返过短自停逻辑退出探测循环。 - 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(); - }); - - void chrome.permissions.contains({ permissions: ["webRequestBlocking"] }).then((granted) => { - if (granted) startLoop(); - }); - } - : () => {}; diff --git a/src/app/service/offscreen/keep_alive.ts b/src/app/service/offscreen/keep_alive.ts new file mode 100644 index 000000000..f2ced2cc2 --- /dev/null +++ b/src/app/service/offscreen/keep_alive.ts @@ -0,0 +1,325 @@ +// 保活实验依赖 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"; + +// 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(); + +export const startFirefoxEventPageKeepAliveLoop = + boolFirefox && process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" && nativeScheduler + ? () => { + let running = false; + let enabled = false; + + // 期望每个 blocking request 保持未完成的时间。 + const DEFAULT_PROBE_DELAY_MS = 10_000; + const MAX_PROBE_DELAY_MS = 120_000; + // 实际往返过短说明 listener 未阻塞请求(通常是权限缺失或请求提前失败); + // 此时停止后续探测,避免失败请求形成紧密循环。 + const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; + + // 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 + const keepAliveProbeUrl = `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; + const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; + + let probeStartedAt: number; + + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免权限缺失导致忙循环。 + const onKeepAliveProbeSettled = function (this: HTMLImageElement) { + this.remove(); + if (!enabled || Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + sendKeepAliveProbe(); + } as any; + + // 发起一次探测请求,作为心跳循环的一拍 + const sendKeepAliveProbe = () => { + const image = new Image(1, 1); + probeStartedAt = Date.now(); + + image.onload = onKeepAliveProbeSettled; + + image.onerror = onKeepAliveProbeSettled; + + image.src = `${keepAliveProbeUrl}?__network_delay=${DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; + document.documentElement.appendChild(image); + }; + + const startLoop = () => { + enabled = true; + if (running) 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 (!enabled || !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, MAX_PROBE_DELAY_MS)) + : DEFAULT_PROBE_DELAY_MS; + + return new Promise((resolve) => { + // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 + nativeScheduler.postTask( + () => { + // 延迟完成后放行 blocking request。 + resolve({}); + }, + { priority: "user-visible", delay: delayMs } + ); + }); + }, + { + urls: [`${keepAliveProbeOrigin}/*`], + types: ["xmlhttprequest", "image"], + }, + ["blocking"] + ); + + sendKeepAliveProbe(); + }; + + // 必须在事件页首个同步回合注册;权限移除时立即关闭探测并保留监听器以便重新授权。 + 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")) enabled = false; + }); + + void chrome.permissions.contains({ permissions: ["webRequestBlocking"] }).then((granted) => { + if (granted) startLoop(); + }); + } + : () => {}; + +// SW 与 offscreen 共用同一条探测 URL 及延迟上限,两端各自独立计算,避免跨上下文传递闭包状态。 +const KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS = 10_000; +const KEEP_ALIVE_MAX_PROBE_DELAY_MS = 120_000; + +const getKeepAliveProbeUrl = () => `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; + +let selfSw: ServiceWorkerGlobalScope | null = null; + +export const setServiceWorkerSelf = (sw: ServiceWorkerGlobalScope) => { + selfSw = sw; +}; + +/** + * 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 + ? () => { + let enabled = false; + + const keepAliveProbeUrl = getKeepAliveProbeUrl(); + + // Helper function to create a delay promise + const delay = (delayMs: number) => + new Promise((resolve) => + nativeScheduler.postTask( + () => { + resolve(); + }, + { priority: "user-visible", delay: delayMs } + ) + ); + + // Base64 string for a valid, minimal 1x1 transparent GIF + const TRANSPARENT_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + + // Convert Base64 to a binary Uint8Array that the Response object can consume + const gifBytes = Uint8Array.from(atob(TRANSPARENT_GIF_BASE64), (c) => c.charCodeAt(0)); + + if (!selfSw) return () => {}; + + selfSw.addEventListener("install", (_e: ExtendableEvent) => self.skipWaiting()); + selfSw.addEventListener("activate", (e: ExtendableEvent) => e.waitUntil(self.clients.claim())); + + 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; + + event.respondWith( + delay(delayMs) // 10-second delay + .then( + () => + // Return a fresh Response containing the image binary + 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 + }, + }) + ) + .catch(() => new Response("Network error occurred", { status: 408 })) + ); + }); + + 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_chrome_scripts_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 + ? () => { + let enabled = false; + + // 实际往返过短说明请求未被 SW 端延迟(通常是 fetch 监听器未注册或请求提前失败); + // 此时停止后续探测,避免失败请求形成紧密循环。 + const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; + + const keepAliveProbeUrl = getKeepAliveProbeUrl(); + + let probeStartedAt: number; + + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束或已被禁用时停止,避免忙循环。 + const onKeepAliveProbeSettled = function (this: HTMLImageElement) { + this.remove(); + if (!enabled || Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + sendKeepAliveProbe(); + } as any; + + // 发起一次探测请求,作为心跳循环的一拍 + const sendKeepAliveProbe = () => { + const image = new Image(1, 1); + probeStartedAt = Date.now(); + + image.onload = onKeepAliveProbeSettled; + + image.onerror = onKeepAliveProbeSettled; + + image.src = `${keepAliveProbeUrl}?__network_delay=${KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; + document.documentElement.appendChild(image); + }; + + return (val: boolean) => { + const wasEnabled = enabled; + enabled = val; + if (enabled && !wasEnabled) { + sendKeepAliveProbe(); + } + }; + } + : () => (_val: boolean) => {}; diff --git a/src/offscreen.ts b/src/offscreen.ts index 70b61ceeb..9cc1cb42c 100644 --- a/src/offscreen.ts +++ b/src/offscreen.ts @@ -2,13 +2,6 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { OffscreenManager } from "./app/service/offscreen"; import { ServiceWorkerClientMessage } from "@Packages/message/window_message"; -import { MessageQueue } from "@Packages/message/message_queue"; -import { SystemConfig } from "./pkg/config/config"; - -const startChromeOffscreenKeepAliveLoop = (enabled: boolean) => { - if (!enabled) return; - // Chrome offscreen 保活循环将在后续实现。 -}; function main() { // 通过postMessage与SW通信,支持结构化克隆(Blob等) @@ -22,8 +15,6 @@ function main() { // 初始化管理器 const manager = new OffscreenManager(swPostMessage); manager.initManager(); - const systemConfig = new SystemConfig(new MessageQueue()); - void systemConfig.getKeepChromeScriptsAlive().then(startChromeOffscreenKeepAliveLoop); } main(); diff --git a/src/service_worker.ts b/src/service_worker.ts index 60c5840a7..39144b45d 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -10,17 +10,15 @@ import { EventPageOffscreenManager, InProcessMessage } from "./app/service/offsc import migrate, { migrateChromeStorage } from "./app/migrate"; import { cleanInvalidKeys } from "./app/repo/resource"; import { SystemConfig } from "./pkg/config/config"; +import { hookServiceWorkerKeepAliveLoop, setServiceWorkerSelf } from "./app/service/offscreen/keep_alive"; + +setServiceWorkerSelf(self as unknown as ServiceWorkerGlobalScope); migrate(); migrateChromeStorage(); const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html"; -const startChromeServiceWorkerKeepAliveLoop = (enabled: boolean) => { - if (!enabled) return; - // Chrome service worker 保活循环将在后续实现。 -}; - let creating: Promise | null | boolean = null; async function hasDocument() { @@ -81,17 +79,17 @@ function main() { const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function"; if (hasOffscreenDocument) { const systemConfig = new SystemConfig(messageQueue); - void systemConfig.getKeepChromeScriptsAlive().then(startChromeServiceWorkerKeepAliveLoop); - } - // 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 offscreen = new ServiceWorkerMessageSend(); const manager = new ServiceWorkerManager(server, messageQueue, offscreen); manager.initManager(); setupOffscreenDocument(); - } else { + hookServiceWorkerKeepAliveLoop(systemConfig, messageQueue, offscreen); + return; + } + // 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 From c9de378f32b003b7b3e70c86838f0b4ed030a2ed Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:57:55 +0900 Subject: [PATCH 27/31] fix SC_KEEP_EVENT_PAGE_ACTIVE --- rspack.config.ts | 2 +- scripts/pack.js | 2 +- src/app/service/offscreen/keep_alive.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index aff8cac87..ffd12488f 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -15,7 +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 === "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 diff --git a/scripts/pack.js b/scripts/pack.js index 48fa75c5c..12cf683a1 100644 --- a/scripts/pack.js +++ b/scripts/pack.js @@ -92,7 +92,7 @@ firefoxManifest.permissions = firefoxManifest.permissions.filter( (val) => val !== "userScripts" && val !== "debugger" && val !== "offscreen" ); -if (process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true") { +if (process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false") { // for startFirefoxEventPageKeepAliveLoop — 运行时按需向用户申请 firefoxManifest.optional_permissions.push("webRequestBlocking"); } diff --git a/src/app/service/offscreen/keep_alive.ts b/src/app/service/offscreen/keep_alive.ts index f2ced2cc2..203da1a2b 100644 --- a/src/app/service/offscreen/keep_alive.ts +++ b/src/app/service/offscreen/keep_alive.ts @@ -34,7 +34,7 @@ const nativeScheduler = const boolFirefox = isFirefox(); export const startFirefoxEventPageKeepAliveLoop = - boolFirefox && process.env.SC_KEEP_EVENT_PAGE_ACTIVE === "true" && nativeScheduler + boolFirefox && process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false" && nativeScheduler ? () => { let running = false; let enabled = false; From 13f98642bb9c133d46632ce3ddded07d88779a37 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:33:02 +0900 Subject: [PATCH 28/31] update --- src/app/service/offscreen/keep_alive.ts | 23 +++++++++++------------ src/app/service/service_worker/index.ts | 6 ++++++ src/service_worker.ts | 8 ++------ 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/app/service/offscreen/keep_alive.ts b/src/app/service/offscreen/keep_alive.ts index 203da1a2b..a08b3ccde 100644 --- a/src/app/service/offscreen/keep_alive.ts +++ b/src/app/service/offscreen/keep_alive.ts @@ -6,6 +6,13 @@ 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; +}; + // API 不可用时直接关闭实验,避免引入另一套行为不一致的计时路径。 const nativeScheduler = //@ts-ignore @@ -154,12 +161,6 @@ const KEEP_ALIVE_MAX_PROBE_DELAY_MS = 120_000; const getKeepAliveProbeUrl = () => `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; -let selfSw: ServiceWorkerGlobalScope | null = null; - -export const setServiceWorkerSelf = (sw: ServiceWorkerGlobalScope) => { - selfSw = sw; -}; - /** * Chrome MV3 service worker 保活实验:仅注册 ServiceWorkerGlobalScope 事件。 * @@ -175,6 +176,9 @@ export const setServiceWorkerSelf = (sw: ServiceWorkerGlobalScope) => { const startChromeServiceWorkerKeepAliveLoop = !boolFirefox && nativeScheduler ? () => { + const selfSw_ = selfSw; + if (!selfSw_) return () => {}; + let enabled = false; const keepAliveProbeUrl = getKeepAliveProbeUrl(); @@ -196,12 +200,7 @@ const startChromeServiceWorkerKeepAliveLoop = // Convert Base64 to a binary Uint8Array that the Response object can consume const gifBytes = Uint8Array.from(atob(TRANSPARENT_GIF_BASE64), (c) => c.charCodeAt(0)); - if (!selfSw) return () => {}; - - selfSw.addEventListener("install", (_e: ExtendableEvent) => self.skipWaiting()); - selfSw.addEventListener("activate", (e: ExtendableEvent) => e.waitUntil(self.clients.claim())); - - selfSw.addEventListener("fetch", (event: FetchEvent) => { + selfSw_.addEventListener("fetch", (event: FetchEvent) => { if (!enabled || !event.request.url.includes(keepAliveProbeUrl)) { return void 0; } 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/service_worker.ts b/src/service_worker.ts index 39144b45d..a2e76829f 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -9,10 +9,9 @@ import { ServiceWorkerMessageSend } from "@Packages/message/window_message"; import { EventPageOffscreenManager, InProcessMessage } from "./app/service/offscreen/event_page_manager"; import migrate, { migrateChromeStorage } from "./app/migrate"; import { cleanInvalidKeys } from "./app/repo/resource"; -import { SystemConfig } from "./pkg/config/config"; -import { hookServiceWorkerKeepAliveLoop, setServiceWorkerSelf } from "./app/service/offscreen/keep_alive"; +import { onServiceWorkerStarted } from "./app/service/offscreen/keep_alive"; -setServiceWorkerSelf(self as unknown as ServiceWorkerGlobalScope); +onServiceWorkerStarted(self as unknown as ServiceWorkerGlobalScope); migrate(); migrateChromeStorage(); @@ -78,15 +77,12 @@ function main() { const messageQueue = new MessageQueue(); const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function"; if (hasOffscreenDocument) { - const systemConfig = new SystemConfig(messageQueue); 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(); - hookServiceWorkerKeepAliveLoop(systemConfig, messageQueue, offscreen); - return; } // Chrome needs a real offscreen document. Firefox MV3 uses EventPageOffscreenManager instead. else { From dde1238c1b13d26e0a3b96b667f74e729113e8d3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:59:13 +0900 Subject: [PATCH 29/31] Update keep_alive.ts --- src/app/service/offscreen/keep_alive.ts | 139 +++++++++++------------- 1 file changed, 64 insertions(+), 75 deletions(-) diff --git a/src/app/service/offscreen/keep_alive.ts b/src/app/service/offscreen/keep_alive.ts index a08b3ccde..0ce9312de 100644 --- a/src/app/service/offscreen/keep_alive.ts +++ b/src/app/service/offscreen/keep_alive.ts @@ -13,6 +13,9 @@ export const onServiceWorkerStarted = (sw: ServiceWorkerGlobalScope) => { selfSw = sw; }; +// 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 +const getKeepAliveProbeUrl = () => `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; + // API 不可用时直接关闭实验,避免引入另一套行为不一致的计时路径。 const nativeScheduler = //@ts-ignore @@ -40,48 +43,65 @@ const nativeScheduler = 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; + + const onKeepAliveProbeSettled = function (this: HTMLImageElement) { + this.remove(); + // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免忙循环。 + if (!enabled || Date.now() - probeStartedAt < KEEP_ALIVE_MIN_ROUND_TRIP_TO_CONTINUE_MS) return; + sendKeepAliveProbe(); + } as any; + + // 发起一次探测请求,作为心跳循环的一拍 + function sendKeepAliveProbe() { + const image = new Image(1, 1); + probeStartedAt = Date.now(); + + image.onload = onKeepAliveProbeSettled; + image.onerror = onKeepAliveProbeSettled; + + image.src = `${keepAliveProbeUrl}?__network_delay=${KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; + document.documentElement.appendChild(image); + } + + return { + start() { + if (enabled) return; + enabled = true; + sendKeepAliveProbe(); + }, + stop() { + enabled = false; + }, + isEnabled() { + return enabled; + }, + }; +}; + export const startFirefoxEventPageKeepAliveLoop = boolFirefox && process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false" && nativeScheduler ? () => { let running = false; - let enabled = false; - - // 期望每个 blocking request 保持未完成的时间。 - const DEFAULT_PROBE_DELAY_MS = 10_000; - const MAX_PROBE_DELAY_MS = 120_000; - // 实际往返过短说明 listener 未阻塞请求(通常是权限缺失或请求提前失败); - // 此时停止后续探测,避免失败请求形成紧密循环。 - const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; - // 使用扩展 ID 派生的探测域名,避免访问真实站点;请求是否最终成功并不重要。 - const keepAliveProbeUrl = `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; + const keepAliveProbeUrl = getKeepAliveProbeUrl(); + const keepAlive = createKeepAliveProbeLoop(keepAliveProbeUrl); const keepAliveProbeOrigin = new URL(keepAliveProbeUrl).origin; - let probeStartedAt: number; - - // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免权限缺失导致忙循环。 - const onKeepAliveProbeSettled = function (this: HTMLImageElement) { - this.remove(); - if (!enabled || Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; - sendKeepAliveProbe(); - } as any; - - // 发起一次探测请求,作为心跳循环的一拍 - const sendKeepAliveProbe = () => { - const image = new Image(1, 1); - probeStartedAt = Date.now(); - - image.onload = onKeepAliveProbeSettled; - - image.onerror = onKeepAliveProbeSettled; - - image.src = `${keepAliveProbeUrl}?__network_delay=${DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; - document.documentElement.appendChild(image); - }; - const startLoop = () => { - enabled = true; - if (running) return; + if (running) { + keepAlive.start(); + return; + } running = true; // blocking listener 只处理带延迟标记的探测请求,并在 delayMs 后放行。 @@ -93,7 +113,7 @@ export const startFirefoxEventPageKeepAliveLoop = console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRequest:", lastError); } - if (!enabled || !details.url.includes(keepAliveProbeUrl)) { + if (!keepAlive.isEnabled() || !details.url.includes(keepAliveProbeUrl)) { return {}; } const url = new URL(details.url); @@ -106,8 +126,8 @@ export const startFirefoxEventPageKeepAliveLoop = const requestedDelay = Number(url.searchParams.get("__network_delay")); const delayMs = Number.isFinite(requestedDelay) - ? Math.max(0, Math.min(requestedDelay, MAX_PROBE_DELAY_MS)) - : DEFAULT_PROBE_DELAY_MS; + ? Math.max(0, Math.min(requestedDelay, KEEP_ALIVE_MAX_PROBE_DELAY_MS)) + : KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS; return new Promise((resolve) => { // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 @@ -127,7 +147,7 @@ export const startFirefoxEventPageKeepAliveLoop = ["blocking"] ); - sendKeepAliveProbe(); + keepAlive.start(); }; // 必须在事件页首个同步回合注册;权限移除时立即关闭探测并保留监听器以便重新授权。 @@ -146,7 +166,7 @@ export const startFirefoxEventPageKeepAliveLoop = console.error("chrome.runtime.lastError in chrome.permissions.onRemoved:", lastError); return; } - if (permissions.permissions?.includes("webRequestBlocking")) enabled = false; + if (permissions.permissions?.includes("webRequestBlocking")) keepAlive.stop(); }); void chrome.permissions.contains({ permissions: ["webRequestBlocking"] }).then((granted) => { @@ -155,12 +175,6 @@ export const startFirefoxEventPageKeepAliveLoop = } : () => {}; -// SW 与 offscreen 共用同一条探测 URL 及延迟上限,两端各自独立计算,避免跨上下文传递闭包状态。 -const KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS = 10_000; -const KEEP_ALIVE_MAX_PROBE_DELAY_MS = 120_000; - -const getKeepAliveProbeUrl = () => `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}`; - /** * Chrome MV3 service worker 保活实验:仅注册 ServiceWorkerGlobalScope 事件。 * @@ -283,42 +297,17 @@ export const hookServiceWorkerKeepAliveLoop = ( export const startChromeOffscreenKeepAliveLoop = !boolFirefox && nativeScheduler ? () => { - let enabled = false; - // 实际往返过短说明请求未被 SW 端延迟(通常是 fetch 监听器未注册或请求提前失败); // 此时停止后续探测,避免失败请求形成紧密循环。 - const MIN_ROUND_TRIP_TO_CONTINUE_MS = 5_000; - const keepAliveProbeUrl = getKeepAliveProbeUrl(); - - let probeStartedAt: number; - - // 只有请求确实被阻塞了足够久才继续下一轮;过快结束或已被禁用时停止,避免忙循环。 - const onKeepAliveProbeSettled = function (this: HTMLImageElement) { - this.remove(); - if (!enabled || Date.now() - probeStartedAt < MIN_ROUND_TRIP_TO_CONTINUE_MS) return; - sendKeepAliveProbe(); - } as any; - - // 发起一次探测请求,作为心跳循环的一拍 - const sendKeepAliveProbe = () => { - const image = new Image(1, 1); - probeStartedAt = Date.now(); - - image.onload = onKeepAliveProbeSettled; - - image.onerror = onKeepAliveProbeSettled; - - image.src = `${keepAliveProbeUrl}?__network_delay=${KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; - document.documentElement.appendChild(image); - }; + const keepAlive = createKeepAliveProbeLoop(keepAliveProbeUrl); return (val: boolean) => { - const wasEnabled = enabled; - enabled = val; - if (enabled && !wasEnabled) { - sendKeepAliveProbe(); + if (val) { + keepAlive.start(); + return; } + keepAlive.stop(); }; } : () => (_val: boolean) => {}; From 77ac32f5826a09a52f96d76c9c4769da2866dae8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:51:46 +0900 Subject: [PATCH 30/31] update --- src/app/service/offscreen/keep_alive.ts | 135 ++++++++++-------- .../Setting/sections/RuntimeSection.test.tsx | 2 +- .../Setting/sections/RuntimeSection.tsx | 4 +- src/pkg/config/config.ts | 8 +- src/pkg/config/consts.ts | 2 +- 5 files changed, 85 insertions(+), 66 deletions(-) diff --git a/src/app/service/offscreen/keep_alive.ts b/src/app/service/offscreen/keep_alive.ts index 0ce9312de..85b3acf53 100644 --- a/src/app/service/offscreen/keep_alive.ts +++ b/src/app/service/offscreen/keep_alive.ts @@ -13,6 +13,17 @@ export const onServiceWorkerStarted = (sw: ServiceWorkerGlobalScope) => { 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]}`; @@ -54,24 +65,54 @@ const createKeepAliveProbeLoop = (keepAliveProbeUrl: string) => { let enabled = false; let probeStartedAt: number; - const onKeepAliveProbeSettled = function (this: HTMLImageElement) { - this.remove(); - // 只有请求确实被阻塞了足够久才继续下一轮;过快结束时停止,避免忙循环。 - if (!enabled || Date.now() - probeStartedAt < KEEP_ALIVE_MIN_ROUND_TRIP_TO_CONTINUE_MS) return; - sendKeepAliveProbe(); - } as any; + // 注意:不要让 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(); + } + }; // 发起一次探测请求,作为心跳循环的一拍 - function sendKeepAliveProbe() { - const image = new Image(1, 1); - probeStartedAt = Date.now(); + const sendKeepAliveProbe = boolFirefox + ? () => { + // Image 特性:网络要求发起不需要跟随 DOM 节点附付 + image = new Image(1, 1); + probeStartedAt = Date.now(); - image.onload = onKeepAliveProbeSettled; - image.onerror = onKeepAliveProbeSettled; + // onload / onerror 在呼叫前 image 不会被 GC + image.onload = onKeepAliveProbeSettled; + image.onerror = onKeepAliveProbeSettled; - image.src = `${keepAliveProbeUrl}?__network_delay=${KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS}&t=${probeStartedAt}`; - document.documentElement.appendChild(image); - } + 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() { @@ -88,6 +129,8 @@ const createKeepAliveProbeLoop = (keepAliveProbeUrl: string) => { }; }; +const TRANSPARENT_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + export const startFirefoxEventPageKeepAliveLoop = boolFirefox && process.env.SC_KEEP_EVENT_PAGE_ACTIVE !== "false" && nativeScheduler ? () => { @@ -97,6 +140,8 @@ export const startFirefoxEventPageKeepAliveLoop = 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(); @@ -129,16 +174,8 @@ export const startFirefoxEventPageKeepAliveLoop = ? Math.max(0, Math.min(requestedDelay, KEEP_ALIVE_MAX_PROBE_DELAY_MS)) : KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS; - return new Promise((resolve) => { - // user-visible 优先级用于尽量减少后台页面降频;它不保证 event page 永久存活。 - nativeScheduler.postTask( - () => { - // 延迟完成后放行 blocking request。 - resolve({}); - }, - { priority: "user-visible", delay: delayMs } - ); - }); + const response = { redirectUrl: dataUri }; + return deferredResponse(response, delayMs); }, { urls: [`${keepAliveProbeOrigin}/*`], @@ -197,22 +234,11 @@ const startChromeServiceWorkerKeepAliveLoop = const keepAliveProbeUrl = getKeepAliveProbeUrl(); - // Helper function to create a delay promise - const delay = (delayMs: number) => - new Promise((resolve) => - nativeScheduler.postTask( - () => { - resolve(); - }, - { priority: "user-visible", delay: delayMs } - ) - ); - - // Base64 string for a valid, minimal 1x1 transparent GIF - const TRANSPARENT_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; - - // Convert Base64 to a binary Uint8Array that the Response object can consume - const gifBytes = Uint8Array.from(atob(TRANSPARENT_GIF_BASE64), (c) => c.charCodeAt(0)); + // 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)) { @@ -231,23 +257,16 @@ const startChromeServiceWorkerKeepAliveLoop = ? Math.max(0, Math.min(requestedDelay, KEEP_ALIVE_MAX_PROBE_DELAY_MS)) : KEEP_ALIVE_DEFAULT_PROBE_DELAY_MS; - event.respondWith( - delay(delayMs) // 10-second delay - .then( - () => - // Return a fresh Response containing the image binary - 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 - }, - }) - ) - .catch(() => new Response("Network error occurred", { status: 408 })) - ); + 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) => { @@ -272,7 +291,7 @@ export const hookServiceWorkerKeepAliveLoop = ( }); }; - systemConfig.watch("keep_chrome_scripts_alive", (value, prev) => { + systemConfig.watch("keep_ext_background_alive", (value, prev) => { enabled = value; setKeepAliveEnabled(value); if (value !== prev) informOffscreen(); diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx index 9cf409fb9..4895740a0 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -96,7 +96,7 @@ describe("运行时分区-可选保活权限", () => { fireEvent.click(screen.getAllByRole("switch")[1]); - expect(set).toHaveBeenCalledWith("keep_chrome_scripts_alive", true); + expect(set).toHaveBeenCalledWith("keep_ext_background_alive", true); }); it("manifest 包含 webRequestBlocking 时显示开关并可请求权限", async () => { diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index ee744b824..88c9f35a0 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -27,7 +27,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT void isPermissionOk("background").then((r) => { if (r !== null) setBg(r); }); - void Promise.resolve(systemConfig.get("keep_chrome_scripts_alive")).then((r) => setChromeKeepAlive(Boolean(r))); + void Promise.resolve(systemConfig.get("keep_ext_background_alive")).then((r) => setChromeKeepAlive(Boolean(r))); } if (boolFirefox) { void isPermissionOk("webRequestBlocking").then((r) => { @@ -69,7 +69,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT const toggleChromeKeepAlive = (enable: boolean) => { setChromeKeepAlive(enable); - systemConfig.set("keep_chrome_scripts_alive", enable); + systemConfig.set("keep_ext_background_alive", enable); }; const toggleKeepAlive = (enable: boolean) => { diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 9e449e8c0..d6a12ecf5 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -376,12 +376,12 @@ export class SystemConfig { this._set("vscode_reconnect", val); } - public getKeepChromeScriptsAlive() { - return this._get("keep_chrome_scripts_alive", false); + public getKeepExtBackgroundAlive() { + return this._get("keep_ext_background_alive", false); } - public setKeepChromeScriptsAlive(val: boolean) { - this._set("keep_chrome_scripts_alive", val); + public setKeepExtBackgroundAlive(val: boolean) { + this._set("keep_ext_background_alive", val); } defaultBackup(): Parameters[0] { diff --git a/src/pkg/config/consts.ts b/src/pkg/config/consts.ts index 32fe81252..ba294b835 100644 --- a/src/pkg/config/consts.ts +++ b/src/pkg/config/consts.ts @@ -10,5 +10,5 @@ export const STORAGE_LOCAL_KEYS: Set = new Set([ "check_update", // 扩展更新通知及已读状态(各设备已读状态独立) "enable_script", // 全局脚本开关(设备独立) "enable_script_incognito", // 隐身模式开关(浏览器级别) - "keep_chrome_scripts_alive", // Chrome service worker 和 offscreen 保活开关 + "keep_ext_background_alive", // Chrome service worker 和 offscreen 保活开关 ]); From ddd90d6f1461538136206dc54b152a6fa5b17f54 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:24:55 +0900 Subject: [PATCH 31/31] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20keep-?= =?UTF-8?q?alive=20=E4=BF=9D=E6=B4=BB=E6=B5=8B=E8=AF=95=EF=BC=9A=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=20Firefox=20=E7=8E=AF=E5=A2=83=E5=B9=B6=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E6=96=B0=E6=8E=A2=E6=B5=8B=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fdfb04ed 把 startFirefoxEventPageKeepAliveLoop 移入 keep_alive.ts 并在模块顶层 加上 isFirefox() 门控;测试环境(happy-dom)非 Firefox,保活分支变成 no-op, event_page_manager.test.ts 中 4 个用例失败。另外 77ac32f5 起探测 不再 挂载到 DOM,且探测 URL 需由 runtime.getURL 派生。 - vi.mock isFirefox(与 RuntimeSection.test.tsx 同一模式)配合 resetModules 生效 - 用可追踪的 Image 替身取代 DOM img 查询来断言探测请求 - 测试内探测 URL 改用与 keep_alive.ts 相同的派生规则 Co-Authored-By: Claude Fable 5 --- .../offscreen/event_page_manager.test.ts | 72 +++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/src/app/service/offscreen/event_page_manager.test.ts b/src/app/service/offscreen/event_page_manager.test.ts index 93a9fab81..2ce7b2daa 100644 --- a/src/app/service/offscreen/event_page_manager.test.ts +++ b/src/app/service/offscreen/event_page_manager.test.ts @@ -8,10 +8,25 @@ import { EventPageOffscreenManager, InProcessMessage } from "./event_page_manage initTestEnv(); +// keep_alive.ts 在模块顶层把 isFirefox() 固化为 boolFirefox 常量;测试环境不是 Firefox, +// 必须 mock isFirefox 并配合 vi.resetModules() 重新导入,Firefox 保活分支才会生效。 +const { isFirefoxMock } = vi.hoisted(() => ({ + isFirefoxMock: vi.fn(() => false), +})); + +vi.mock("@App/pkg/utils/utils", async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, isFirefox: isFirefoxMock }; +}); + +const RealImage = globalThis.Image; + afterEach(() => { vi.unstubAllEnvs(); vi.stubGlobal("scheduler", undefined); vi.restoreAllMocks(); + isFirefoxMock.mockReturnValue(false); + vi.stubGlobal("Image", RealImage); chromeMock.init(); for (const node of Array.from(document.documentElement.querySelectorAll("iframe,img"))) { node.remove(); @@ -23,6 +38,20 @@ const loadKeepAliveEnabledManager = async () => { vi.stubGlobal("scheduler", { postTask: vi.fn(), }); + isFirefoxMock.mockReturnValue(true); + // Firefox 分支的探测 不再挂载到 DOM(仅靠变量引用防 GC),改用可追踪的 Image 替身观察探测请求 + const probeImages: Array<{ src: string; onload: unknown; onerror: unknown }> = []; + vi.stubGlobal( + "Image", + class { + onload: unknown = null; + onerror: unknown = null; + src = ""; + constructor() { + probeImages.push(this); + } + } + ); vi.resetModules(); const loggerModule = await import("../../logger/core.js"); const LoggerCore = loggerModule.default as unknown as typeof LoggerCoreType; @@ -33,7 +62,7 @@ const loadKeepAliveEnabledManager = async () => { labels: { env: "test" }, }); logger.logger().debug("test start"); - return import("./event_page_manager.js"); + return { ...(await import("./event_page_manager.js")), probeImages }; }; // 单测重点:Firefox MV3 下事件页(EventPageOffscreenManager)与 service worker 是同一个脚本/进程, @@ -118,33 +147,43 @@ describe("EventPageOffscreenManager 与 SW 共用 MessageQueue", () => { describe("EventPageOffscreenManager Firefox event page 保活权限门控", () => { it("权限未授予时不注册 blocking listener,也不发起首拍探测", async () => { chromeMock.permissions.__setGrantedPermissions([]); - const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = - await loadKeepAliveEnabledManager(); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + probeImages, + } = 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); + expect(probeImages).toHaveLength(0); }); it("权限已授予时注册 blocking listener 并发起首拍探测", async () => { chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); - const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = - await loadKeepAliveEnabledManager(); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + probeImages, + } = 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); + expect(probeImages).toHaveLength(1); + expect(probeImages[0].src).toContain("__network_delay="); }); it("运行中收到 webRequestBlocking 授权后启动循环,重复触发不会重复注册", async () => { chromeMock.permissions.__setGrantedPermissions([]); - const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = - await loadKeepAliveEnabledManager(); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + probeImages, + } = await loadKeepAliveEnabledManager(); new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); await Promise.resolve(); @@ -153,20 +192,24 @@ describe("EventPageOffscreenManager Firefox event page 保活权限门控", () = chrome.permissions.request({ permissions: ["webRequestBlocking"] }); expect((chrome.webRequest.onBeforeRequest as any).listeners).toHaveLength(1); - expect(document.documentElement.querySelectorAll("img")).toHaveLength(1); + expect(probeImages).toHaveLength(1); }); it("移除 webRequestBlocking 后立即停止 blocking 探测,重新授权后恢复", async () => { chromeMock.permissions.__setGrantedPermissions(["webRequestBlocking"]); - const { EventPageOffscreenManager: KeepAliveManager, InProcessMessage: KeepAliveMessage } = - await loadKeepAliveEnabledManager(); + const { + EventPageOffscreenManager: KeepAliveManager, + InProcessMessage: KeepAliveMessage, + probeImages, + } = await loadKeepAliveEnabledManager(); new KeepAliveManager(new KeepAliveMessage(), new MessageQueue()); await Promise.resolve(); const listener = (chrome.webRequest.onBeforeRequest as any).listeners[0].callback; + // 与 keep_alive.ts 相同的派生规则:探测域名来自扩展自身的 runtime.getURL const probeRequest = { - url: "https://--extensions-test.invalid/dummy_image.png?__network_delay=10000", + url: `https://--extensions-${chrome.runtime.getURL("/dummy_image.png").split("//")[1]}?__network_delay=10000`, }; expect(listener(probeRequest)).toBeInstanceOf(Promise); @@ -178,7 +221,8 @@ describe("EventPageOffscreenManager Firefox event page 保活权限门控", () = chrome.permissions.request({ permissions: ["webRequestBlocking"] }); expect(listener(probeRequest)).toBeInstanceOf(Promise); - expect(document.documentElement.querySelectorAll("img")).toHaveLength(1); + // 首拍探测 + 重新授权后的恢复探测 + expect(probeImages).toHaveLength(2); }); it("构造同步回合内先注册 permissions.onAdded,再等待 contains 结果", async () => {