diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts
index b6a023f44..dcd6530ce 100644
--- a/e2e/gm-api.spec.ts
+++ b/e2e/gm-api.spec.ts
@@ -130,10 +130,10 @@ async function runTestScript(
page.on("console", (msg) => {
const text = msg.text();
logs.push(text);
- const passMatch = text.match(/通过[::]\s*(\d+)/);
- const failMatch = text.match(/失败[::]\s*(\d+)/);
- if (passMatch) passed = parseInt(passMatch[1], 10);
- if (failMatch) failed = parseInt(failMatch[1], 10);
+ const passMatch = text.match(/(通过|Passed)[::]\s*(\d+)/);
+ const failMatch = text.match(/(失败|Failed)[::]\s*(\d+)/);
+ if (passMatch) passed = parseInt(passMatch[2], 10);
+ if (failMatch) failed = parseInt(failMatch[2], 10);
if (passed >= 0 && failed >= 0) resolve();
});
});
@@ -152,12 +152,18 @@ test.describe("GM API", () => {
// Two-phase launch + script install + network fetches + permission dialogs
test.setTimeout(300_000);
- test("GM_ sync API tests (gm_api_test.js)", async ({ context, extensionId }) => {
- const { passed, failed, logs } = await runTestScript(context, extensionId, "gm_api_test.js", TARGET_URL, 90_000);
+ test("GM_ sync API tests (gm_api_sync_test.js)", async ({ context, extensionId }) => {
+ const { passed, failed, logs } = await runTestScript(
+ context,
+ extensionId,
+ "gm_api_sync_test.js",
+ `${TARGET_URL}?gm_api_sync`,
+ 90_000
+ );
- console.log(`[gm_api_test] passed=${passed}, failed=${failed}`);
+ console.log(`[gm_api_sync_test] passed=${passed}, failed=${failed}`);
if (failed !== 0) {
- console.log("[gm_api_test] logs:", logs.join("\n"));
+ console.log("[gm_api_sync_test] logs:", logs.join("\n"));
}
expect(failed, "Some GM_ sync API tests failed").toBe(0);
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
@@ -168,7 +174,7 @@ test.describe("GM API", () => {
context,
extensionId,
"gm_api_async_test.js",
- TARGET_URL,
+ `${TARGET_URL}?gm_api_async`,
90_000
);
@@ -185,7 +191,7 @@ test.describe("GM API", () => {
context,
extensionId,
"inject_content_test.js",
- TARGET_URL,
+ `${TARGET_URL}?inject_content`,
60_000
);
@@ -196,4 +202,21 @@ test.describe("GM API", () => {
expect(failed, "Some content inject tests failed").toBe(0);
expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
});
+
+ test("WindowMessage Transport Test (window_message_test.js)", async ({ context, extensionId }) => {
+ const { passed, failed, logs } = await runTestScript(
+ context,
+ extensionId,
+ "window_message_test.js",
+ `${TARGET_URL}?WINDOW_MESSAGE_TEST_SC`,
+ 8_000
+ );
+
+ console.log(`[window_message_test] passed=${passed}, failed=${failed}`);
+ if (failed !== 0) {
+ console.log("[window_message_test] logs:", logs.join("\n"));
+ }
+ expect(failed, "Some tests failed").toBe(0);
+ expect(passed, "No test results found - script may not have run").toBeGreaterThan(0);
+ });
});
diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js
index 1abfa9803..ad28dcfed 100644
--- a/example/tests/early_inject_content_test.js
+++ b/example/tests/early_inject_content_test.js
@@ -1,9 +1,9 @@
// ==UserScript==
-// @name 早期脚本 注入到 content环境测试
+// @name Early-start Test (content 环境)
// @namespace https://docs.scriptcat.org/
// @version 0.1.0
-// @description 早期脚本可以比页面更早到执行
-// @match https://content-security-policy.com/
+// @description early-start 可以比 document-start 更早执行
+// @match https://content-security-policy.com/?early_inject_content
// @inject-into content
// @early-start
// @grant GM_addElement
diff --git a/example/tests/early_test.js b/example/tests/early_inject_page_test.js
similarity index 98%
rename from example/tests/early_test.js
rename to example/tests/early_inject_page_test.js
index 9ff6581d7..9d6e9d0bb 100644
--- a/example/tests/early_test.js
+++ b/example/tests/early_inject_page_test.js
@@ -1,9 +1,9 @@
// ==UserScript==
-// @name 早期脚本
+// @name Early-start Test (page 环境)
// @namespace https://docs.scriptcat.org/
// @version 0.1.0
-// @description 早期脚本可以比页面更早到执行
-// @match https://content-security-policy.com/
+// @description early-start 可以比 document-start 更早执行
+// @match https://content-security-policy.com/?early_inject_page
// @early-start
// @grant GM_addElement
// @grant GM_addStyle
diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js
index 3e65a2fdb..749d7b1fb 100644
--- a/example/tests/gm_api_async_test.js
+++ b/example/tests/gm_api_async_test.js
@@ -4,7 +4,7 @@
// @version 1.0.0
// @description 全面测试ScriptCat的所有GM.* (异步Promise版本) API功能
// @author ScriptCat
-// @match https://content-security-policy.com/
+// @match https://content-security-policy.com/?gm_api_async
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
diff --git a/example/tests/gm_api_test.js b/example/tests/gm_api_sync_test.js
similarity index 99%
rename from example/tests/gm_api_test.js
rename to example/tests/gm_api_sync_test.js
index a563ea5e7..0b268974c 100644
--- a/example/tests/gm_api_test.js
+++ b/example/tests/gm_api_sync_test.js
@@ -1,10 +1,10 @@
// ==UserScript==
-// @name GM API 完整测试
+// @name GM API 完整测试 (同步版本)
// @namespace https://docs.scriptcat.org/
// @version 1.1.0
// @description 全面测试ScriptCat的所有GM API功能
// @author ScriptCat
-// @match https://content-security-policy.com/
+// @match https://content-security-policy.com/?gm_api_sync
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js
new file mode 100644
index 000000000..c54682c6f
--- /dev/null
+++ b/example/tests/gm_xhr_redirect_test.js
@@ -0,0 +1,244 @@
+// ==UserScript==
+// @name xhr_redirect_test
+// @namespace tm-gmxhr-test
+// @version 0.1.0
+// @description Comprehensive in-page tests for GM_xmlhttpRequest: normal, abnormal, and edge cases with clear pass/fail output.
+// @author you
+// @match *://*/*?GM_XHR_REDIRECT_TEST_SC
+// @grant GM_xmlhttpRequest
+// @connect httpbun.com
+// @noframes
+// ==/UserScript==
+
+const enableTool = true;
+(function () {
+ "use strict";
+ if (!enableTool) return;
+
+ // ---------- Panel ----------
+
+ const panel = document.createElement("div");
+ panel.id = "gmxhr-test-panel";
+ panel.innerHTML = `
+
+
+
+
GM_xmlhttpRequest Test Harness
+
…
+
+
+
+
+ Status: idle
+
+ `;
+ document.documentElement.append(panel);
+
+ panel.querySelector("#ver").textContent = GM.info?.script?.version ?? "";
+ panel.querySelector("#handler").textContent = `${GM.info?.scriptHandler} ${GM.info?.version}`;
+
+ const $log = panel.querySelector("#log");
+ const $counts = panel.querySelector("#counts");
+ const $status = panel.querySelector("#status");
+
+ panel.querySelector("#clear").addEventListener("click", () => {
+ $log.textContent = "";
+ setCounts(0, 0, 0);
+ $status.textContent = "Status: idle";
+ });
+ panel.querySelector("#start").addEventListener("click", runAll);
+
+ function logLine(html) {
+ const el = document.createElement("div");
+ el.innerHTML = html;
+ $log.prepend(el);
+ }
+
+ function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, m =>
+ ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[m]);
+ }
+
+ const state = { pass: 0, fail: 0, skip: 0 };
+ function setCounts(p, f, s) { $counts.textContent = `✅ ${p} ❌ ${f} ⏳ ${s}`; }
+ function pass(msg) { state.pass++; setCounts(state.pass, state.fail, state.skip); logLine(`✅ ${escapeHtml(msg)}`); }
+ function fail(msg, extra) {
+ state.fail++; setCounts(state.pass, state.fail, state.skip);
+ logLine(`❌ ${escapeHtml(msg)}${extra ? `${escapeHtml(extra)}` : ""}`);
+ }
+ function skip(msg) { state.skip++; setCounts(state.pass, state.fail, state.skip); logLine(`⏭️ ${escapeHtml(msg)}`); }
+
+ // ---------- Request helper ----------
+ function gmRequest(details, { abortAfterMs } = {}) {
+ return new Promise((resolve, reject) => {
+ const t0 = performance.now();
+ const req = GM_xmlhttpRequest({
+ ...details,
+ onload: res => resolve({ kind: "load", res, ms: performance.now() - t0 }),
+ onerror: res => reject ({ kind: "error", res, ms: performance.now() - t0 }),
+ ontimeout: res => reject ({ kind: "timeout", res, ms: performance.now() - t0 }),
+ onabort: res => reject ({ kind: "abort", res, ms: performance.now() - t0 }),
+ onprogress: details.onprogress,
+ });
+ if (abortAfterMs != null) setTimeout(() => { try { req.abort(); } catch (_) {} }, abortAfterMs);
+ });
+ }
+
+ const HB = "https://httpbun.com";
+
+ // ---------- Assertion utils ----------
+ function assertEq(a, b, msg) {
+ if (a !== b) throw new Error(msg ? `${msg}: expected ${b}, got ${a}` : `expected ${b}, got ${a}`);
+ }
+
+ function objectProps(o) {
+ if (!o || typeof o !== "object") return "not an object";
+ let z, oD, zD;
+ try { z = Object.assign({}, o); } catch { return "Object.assign failed"; }
+ if (typeof (z.response ?? "") !== "string") return "non-primitive response value exposed";
+ if (typeof (z.responseText ?? "") !== "string") return "non-primitive responseText value exposed";
+ if (typeof (z.responseXML ?? "") !== "string") return "non-primitive responseXML value exposed";
+ try { oD = JSON.stringify(o); } catch { return "JSON.stringify failed"; }
+ try { zD = JSON.stringify(z); } catch { return "JSON.stringify failed"; }
+ if (oD !== zD) return "Object Props Failed";
+ return "ok";
+ }
+
+ // ---------- Tests ----------
+ const basicTests = [
+ {
+ name: 'GET basic with search params 1',
+ async run(fetch) {
+ const { res } = await gmRequest({ method: "GET", url: `${HB}/get?testing=234&abc=567`, responseType: "json", fetch });
+ assertEq(res.status, 200, "status 200");
+ assertEq(res.response?.args?.testing, "234", "response ok");
+ assertEq(res.response?.args?.abc, "567", "response ok");
+ assertEq(res.response?.url, `${HB}/get?testing=234&abc=567`, "response ok");
+ assertEq(objectProps(res), "ok", "Object Props OK");
+ },
+ },
+ {
+ name: 'GET basic with search params 2',
+ async run(fetch) {
+ const { res } = await gmRequest({ method: "GET", url: `${HB}/get?abc=567&testing=234`, responseType: "json", fetch });
+ assertEq(res.status, 200, "status 200");
+ assertEq(res.response?.args?.testing, "234", "response ok");
+ assertEq(res.response?.args?.abc, "567", "response ok");
+ assertEq(res.response?.url, `${HB}/get?abc=567&testing=234`, "response ok");
+ assertEq(objectProps(res), "ok", "Object Props OK");
+ },
+ },
+ {
+ name: "Redirect handling (finalUrl changes) [default]",
+ async run(fetch) {
+ const target = `${HB}/get?z=92`;
+ const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, fetch });
+ assertEq(res.status, 200, "status after redirect is 200");
+ assertEq(res.finalUrl, target, "finalUrl is redirected target");
+ assertEq(objectProps(res), "ok", "Object Props OK");
+ },
+ },
+ {
+ name: "Redirect handling (finalUrl changes) [follow]",
+ async run(fetch) {
+ const target = `${HB}/get?z=94`;
+ const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, redirect: "follow", fetch });
+ assertEq(res.status, 200, "status after redirect is 200");
+ assertEq(res.finalUrl, target, "finalUrl is redirected target");
+ assertEq(objectProps(res), "ok", "Object Props OK");
+ },
+ },
+ {
+ name: "Redirect handling (finalUrl changes) [error]",
+ async run(fetch) {
+ try {
+ await Promise.race([
+ gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(`${HB}/get?z=96`)}`, redirect: "error", fetch }),
+ new Promise(resolve => setTimeout(resolve, 4000)),
+ ]);
+ throw new Error("Expected error, got load");
+ } catch (e) {
+ assertEq(e?.kind, "error", "error ok");
+ assertEq(e?.res?.status, 408, "statusCode ok");
+ assertEq(!e?.res?.finalUrl, true, "!finalUrl ok");
+ assertEq(e?.res?.responseHeaders, "", "responseHeaders ok");
+ assertEq(objectProps(e?.res), "ok", "Object Props OK");
+ }
+ },
+ },
+ {
+ name: "Redirect handling (finalUrl changes) [manual]",
+ async run(fetch) {
+ const url = `${HB}/redirect-to?url=${encodeURIComponent(`${HB}/get?z=98`)}`;
+ const { res } = await Promise.race([
+ gmRequest({ method: "GET", url, redirect: "manual", fetch }),
+ new Promise(resolve => setTimeout(resolve, 4000)),
+ ]);
+ assertEq(res?.status, 301, "status is 301");
+ assertEq(res?.finalUrl, url, "finalUrl is original url");
+ assertEq(typeof res?.responseHeaders === "string" && res?.responseHeaders !== "", true, "responseHeaders ok");
+ assertEq(objectProps(res), "ok", "Object Props OK");
+ },
+ },
+ ];
+
+ const tests = [
+ ...basicTests,
+ ...basicTests.map(t => ({ ...t, useFetch: true })),
+ ];
+
+ // ---------- Runner ----------
+ function fmtMs(ms) { return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; }
+
+ async function runAll() {
+ state.pass = state.fail = state.skip = 0;
+ setCounts(0, 0, 0);
+ logLine(`Starting GM_xmlhttpRequest test suite — ${new Date().toLocaleString()}`);
+
+ for (let i = 0; i < tests.length; i++) {
+ const t = tests[i];
+ const tName = `${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`;
+ $status.textContent = `Status: running (${i + 1}/${tests.length}): ${tName}`;
+ logLine(`▶️ ${escapeHtml(tName)}`);
+ const t0 = performance.now();
+ try {
+ await t.run(t.useFetch ? true : false);
+ pass(`• ${tName} (${fmtMs(performance.now() - t0)})`);
+ } catch (e) {
+ console.error(e);
+ const stack = e?.stack ? e.stack.split("\n").slice(0, 4).join("\n") : null;
+ fail(`• ${tName} (${fmtMs(performance.now() - t0)})`, [e?.message, stack].filter(Boolean).join("\n"));
+ }
+ }
+
+ $status.textContent = "Status: done";
+ logLine(`Done. ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`);
+ }
+
+ setTimeout(() => {
+ if (!window.__gmxhr_test_autorun__) {
+ window.__gmxhr_test_autorun__ = true;
+ runAll();
+ }
+ }, 600);
+})();
\ No newline at end of file
diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js
index fa8cef8ae..2cccdf56a 100644
--- a/example/tests/gm_xhr_test.js
+++ b/example/tests/gm_xhr_test.js
@@ -1,7 +1,7 @@
// ==UserScript==
// @name GM_xmlhttpRequest Exhaustive Test Harness v3
// @namespace tm-gmxhr-test
-// @version 1.2.5
+// @version 1.3.0
// @description Comprehensive in-page tests for GM_xmlhttpRequest: normal, abnormal, and edge cases with clear pass/fail output.
// @author you
// @match *://*/*?GM_XHR_TEST_SC
@@ -85,6 +85,8 @@ const enableTool = true;
return `${a};r=${b1};t=${b2};x=${b3}`;
};
+ const isFirefox = typeof mozInnerScreenX === "number";
+
// ---------- Test Panel ----------
const panel = h(
"div",
@@ -191,6 +193,201 @@ const enableTool = true;
$queue.textContent = items.length ? items.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none)";
}
+ // ---------- Pretty Stack -----------
+ function prettyStack(errorOrStack, options = {}) {
+ const {
+ stripQuery = true,
+ decode = true,
+ maxUrlLength = 90,
+ dropExtensionUuid = true,
+ pathSegments = 2,
+ indent = " ",
+ minFnWidth = 8,
+ maxFnWidth = 48,
+ minLocWidth = 5,
+ maxLocWidth = 12,
+ maxLines = -1,
+ } = options;
+
+ const rawStack =
+ typeof errorOrStack === "string"
+ ? errorOrStack
+ : errorOrStack && errorOrStack.stack
+ ? errorOrStack.stack
+ : String(errorOrStack);
+
+ const lines = rawStack.split(/\r?\n/).filter(Boolean);
+
+ const frames = lines
+ .filter((line, j) => maxLines > 0 ? j < maxLines : true)
+ .map(parseStackLine)
+ .filter(Boolean)
+ .map(frame => ({
+ ...frame,
+ fn: cleanFunctionName(frame.fn),
+ file: cleanFileName(frame.file, {
+ stripQuery,
+ decode,
+ maxUrlLength,
+ dropExtensionUuid,
+ pathSegments,
+ }),
+ }));
+
+ if (!frames.length) return rawStack;
+
+ const fnWidth = clamp(
+ frames.reduce((max, f) => Math.max(max, f.fn.length), minFnWidth),
+ minFnWidth,
+ maxFnWidth
+ );
+
+ const locWidth = clamp(
+ frames.reduce((max, f) => {
+ return Math.max(max, `${f.line}:${f.col}`.length);
+ }, minLocWidth),
+ minLocWidth,
+ maxLocWidth
+ );
+
+ return frames
+ .map(f => {
+ const fn = padRight(truncateMiddle(f.fn, fnWidth), fnWidth);
+ const loc = padLeft(`${f.line}:${f.col}`, locWidth);
+ return `${indent}${fn} ${loc} ${f.file}`;
+ })
+ .join("\n");
+ }
+
+ function parseStackLine(line) {
+ const s = line.trim();
+
+ // Chrome / V8:
+ // at fn (file:line:col)
+ //
+ // Examples:
+ // at run (https://example.com/app.js:10:5)
+ // at async runAll (https://example.com/app.js:20:9)
+ // at new Foo (https://example.com/app.js:30:11)
+ let m = s.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/);
+ if (m) {
+ return {
+ fn: m[1],
+ file: m[2],
+ line: Number(m[3]),
+ col: Number(m[4]),
+ };
+ }
+
+ // Chrome / V8 anonymous:
+ // at file:line:col
+ m = s.match(/^at\s+(.+):(\d+):(\d+)$/);
+ if (m) {
+ return {
+ fn: "",
+ file: m[1],
+ line: Number(m[2]),
+ col: Number(m[3]),
+ };
+ }
+
+ // Firefox:
+ // fn@file:line:col
+ // async*fn@file:line:col
+ // setTimeout handler*fn@file:line:col
+ //
+ // Use a greedy file capture so the last two numeric groups win.
+ m = s.match(/^(.*?)@(.+):(\d+):(\d+)$/);
+ if (m) {
+ return {
+ fn: m[1] || "",
+ file: m[2],
+ line: Number(m[3]),
+ col: Number(m[4]),
+ };
+ }
+
+ return null;
+ }
+
+ function cleanFunctionName(fn) {
+ let s = String(fn).trim();
+
+ if (!s) return "";
+ if (s === "") return s;
+
+ return s
+ .replace(/^async\*/, "async ")
+ .replace(/^setTimeout handler\*/, "timer ")
+ .replace(/^promise callback\*/, "promise ")
+ .replace(/\["#-[^"]+"\]/g, "[userscript]")
+ .replace(/\/+/g, "")
+ .replace(/\s+/g, " ")
+ .trim() || "";
+ }
+
+ function cleanFileName(file, options) {
+ let s = String(file);
+
+ if (options.stripQuery) {
+ s = s.replace(/[?#].*$/, "");
+ }
+
+ if (options.dropExtensionUuid) {
+ s = s.replace(
+ /^(?:moz|chrome)-extension:\/\/[^/]+\//,
+ "extension://"
+ );
+ }
+
+ let parts = s.split("/");
+
+ if (options.pathSegments > 0) {
+ parts = parts.slice(-options.pathSegments);
+ }
+
+ if (options.decode) {
+ parts = parts.map(part => {
+ try {
+ return decodeURIComponent(part);
+ } catch {
+ return part;
+ }
+ });
+ }
+
+ s = parts.join("/");
+
+ return truncateMiddle(s, options.maxUrlLength);
+ }
+
+ function truncateMiddle(value, max) {
+ const str = String(value);
+
+ if (!Number.isFinite(max) || max <= 0) return "";
+ if (str.length <= max) return str;
+ if (max <= 3) return str.slice(0, max);
+
+ const available = max - 1;
+ const left = Math.ceil(available / 2);
+ const right = Math.floor(available / 2);
+
+ return `${str.slice(0, left)}…${str.slice(-right)}`;
+ }
+
+ function padRight(value, width) {
+ return String(value).padEnd(width, " ");
+ }
+
+ function padLeft(value, width) {
+ return String(value).padStart(width, " ");
+ }
+
+ function clamp(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+ }
+
// ---------- Assertion & request helpers ----------
const state = { pass: 0, fail: 0, skip: 0 };
function pass(msg) {
@@ -1536,11 +1733,11 @@ const enableTool = true;
"onreadystatechange 2.200;r=missing;t=missing;x=missing",
"onreadystatechange 3.200;r=missing;t=missing;x=missing",
"onprogress 3.200;r=missing;t=missing;x=missing",
- "onprogress 4.200;r=missing;t=missing;x=missing",
+ isFirefox ? "" : "onprogress 4.200;r=missing;t=missing;x=missing",
"onreadystatechange 4.200;r=;t=string;x=XMLDocument",
"onload 4.200;r=;t=string;x=XMLDocument",
"onloadend 4.200;r=;t=string;x=XMLDocument",
- ],
+ ].filter(Boolean),
"standard-type GMXhr OK"
);
} else {
@@ -1600,11 +1797,11 @@ const enableTool = true;
"onreadystatechange 2.200;r=missing;t=missing;x=missing",
"onreadystatechange 3.200;r=missing;t=missing;x=missing",
"onprogress 3.200;r=missing;t=missing;x=missing",
- "onprogress 4.200;r=missing;t=missing;x=missing",
+ isFirefox ? "" : "onprogress 4.200;r=missing;t=missing;x=missing",
"onreadystatechange 4.200;r=object;t=string;x=XMLDocument",
"onload 4.200;r=object;t=string;x=XMLDocument",
"onloadend 4.200;r=object;t=string;x=XMLDocument",
- ],
+ ].filter(Boolean),
"standard-type GMXhr OK"
);
} else {
@@ -1795,16 +1992,18 @@ const enableTool = true;
for (let i = 0; i < tests.length; i++) {
const t = tests[i];
- const title = `• ${t.name}`;
+ const tName = `${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`;
+ const title = `• ${tName}`;
const t0 = performance.now();
- setStatus(`running (${i + 1}/${tests.length}): ${t.name}`);
+ setStatus(`running (${i + 1}/${tests.length}): ${tName}`);
try {
- logLine(`▶️ ${escapeHtml(t.name)} (queued: ${tests.length - i - 1} remaining)`);
+ logLine(`▶️ ${escapeHtml(tName)} (queued: ${tests.length - i - 1} remaining)`);
await t.run(t.useFetch ? true : false);
pass(`${title} (${fmtMs(performance.now() - t0)})`);
} catch (e) {
- const extra = e && e.stack ? e.stack : String(e);
- fail(`${title} (${fmtMs(performance.now() - t0)})`, extra);
+ console.error(e);
+ const extra = e && e.stack ? prettyStack(e, { maxLines: 4 }) : null;
+ fail(`${title} (${fmtMs(performance.now() - t0)})`, [e?.message, extra].filter(Boolean).join("\n"));
} finally {
// update pending list
setQueue(names.slice(i + 1));
diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js
index b16c9d25e..e34dd5805 100644
--- a/example/tests/inject_content_test.js
+++ b/example/tests/inject_content_test.js
@@ -1,9 +1,9 @@
// ==UserScript==
-// @name 注入到 content 环境测试
+// @name Inject-into content 环境测试
// @namespace https://docs.scriptcat.org/
// @version 0.1.0
// @description 脚本注入到content环境,应该可以绕过CSP检测,但无法访问页面的window
-// @match https://content-security-policy.com/
+// @match https://content-security-policy.com/?inject_content
// @inject-into content
// @grant GM_addElement
// @grant GM_addStyle
diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js
new file mode 100644
index 000000000..444e21179
--- /dev/null
+++ b/example/tests/window_message_test.js
@@ -0,0 +1,176 @@
+// ==UserScript==
+// @name WindowMessage Transport Test
+// @namespace https://docs.scriptcat.org/
+// @version 0.1.0
+// @description Verifies the WindowMessage paths used by ScriptCat sandbox and offscreen pages.
+// @author ScriptCat
+// @match *://*/*?WINDOW_MESSAGE_TEST_SC
+// @grant GM.xmlHttpRequest
+// @grant GM_xmlhttpRequest
+// @grant GM.setClipboard
+// @grant unsafeWindow
+// @connect httpbun.com
+// @run-at document-end
+// @noframes
+// ==/UserScript==
+
+(async function () {
+ "use strict";
+
+ const results = {
+ passed: 0,
+ failed: 0,
+ total: 0,
+ };
+
+ console.log(
+ "%c=== WindowMessage transport test start ===",
+ "color: blue; font-size: 16px; font-weight: bold;",
+ );
+ console.log(
+ "This userscript exercises the production WindowMessage route used by the sandbox/offscreen document. Run it on a URL ending with ?WINDOW_MESSAGE_TEST_SC.",
+ );
+
+ function section(name) {
+ console.log(`\n%c--- ${name} ---`, "color: orange; font-weight: bold;");
+ }
+
+ function assertSame(expected, actual, message) {
+ if (!Object.is(expected, actual)) {
+ throw new Error(
+ `${message} - expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
+ );
+ }
+ }
+
+ function assertTrue(condition, message) {
+ if (!condition) {
+ throw new Error(message || "Assertion failed");
+ }
+ }
+
+ function withTimeout(promise, label, ms = 10000) {
+ let timer = null;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
+ });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+ }
+
+ async function test(name, fn) {
+ results.total++;
+ try {
+ await fn();
+ results.passed++;
+ console.log(`%cPASS ${name}`, "color: green;");
+ return true;
+ } catch (error) {
+ results.failed++;
+ console.error(`%cFAIL ${name}`, "color: red;", error);
+ return false;
+ }
+ }
+
+ section("Sandbox endpoint");
+
+ await test("default userscript runs in the sandbox window", () => {
+ assertSame("object", typeof unsafeWindow, "unsafeWindow should be available");
+ assertTrue(window !== unsafeWindow, "window should be the sandbox window, not the page window");
+ assertSame(window, self, "self should point at the sandbox window");
+ assertSame(window, globalThis, "globalThis should point at the sandbox window");
+ });
+
+ section("One-shot sendMessage path");
+
+ await test("GM.setClipboard resolves through the offscreen sendMessage bridge", async () => {
+ const text = `ScriptCat WindowMessage ${Date.now()} ${Math.random().toString(36).slice(2)}`;
+ await withTimeout(GM.setClipboard(text, { type: "text", mimetype: "text/plain" }), "GM.setClipboard");
+ });
+
+ section("Long-lived connect path");
+
+ await test("GM.xmlHttpRequest receives offscreen response data over a connect channel", async () => {
+ const marker = `window-message-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const response = await withTimeout(
+ GM.xmlHttpRequest({
+ method: "GET",
+ url: `https://httpbun.com/get?marker=${encodeURIComponent(marker)}`,
+ responseType: "json",
+ }),
+ "GM.xmlHttpRequest",
+ );
+
+ assertSame(200, response.status, "status should be 200");
+ assertTrue(response.finalUrl.includes("httpbun.com/get"), "finalUrl should be populated");
+ assertTrue(typeof response.responseHeaders === "string", "responseHeaders should be a string");
+ assertTrue(response.response && typeof response.response === "object", "JSON response should be parsed");
+
+ const args =
+ response.response.args ||
+ response.response.query ||
+ response.response.params ||
+ {};
+ assertSame(marker, args.marker, "query marker should round-trip through the response");
+ });
+
+ await test("GM_xmlhttpRequest forwards readyState events over the connect channel", async () => {
+ const states = [];
+ const response = await withTimeout(
+ new Promise((resolve, reject) => {
+ GM_xmlhttpRequest({
+ method: "GET",
+ url: "https://httpbun.com/bytes/64",
+ onreadystatechange: (res) => {
+ states.push(res.readyState);
+ },
+ onload: resolve,
+ onerror: reject,
+ ontimeout: reject,
+ timeout: 10000,
+ });
+ }),
+ "GM_xmlhttpRequest readyState",
+ );
+
+ assertSame(200, response.status, "status should be 200");
+ assertTrue(states.includes(4), "readyState DONE should be observed");
+ assertTrue(response.responseText.length > 0, "responseText should contain the payload");
+ });
+
+ await test("GM_xmlhttpRequest abort disconnects a pending connect channel", async () => {
+ await withTimeout(
+ new Promise((resolve, reject) => {
+ const request = GM_xmlhttpRequest({
+ method: "GET",
+ url: "https://httpbun.com/delay/5",
+ onload: () => reject(new Error("request loaded before abort")),
+ onerror: reject,
+ ontimeout: reject,
+ onabort: (res) => {
+ try {
+ assertSame(0, res.readyState, "aborted readyState should be UNSENT");
+ assertSame(0, res.status, "aborted status should be 0");
+ resolve();
+ } catch (error) {
+ reject(error);
+ }
+ },
+ timeout: 10000,
+ });
+ setTimeout(() => request.abort(), 100);
+ }),
+ "GM_xmlhttpRequest abort",
+ );
+ });
+
+ console.log(
+ "\n%c=== WindowMessage transport test complete ===",
+ "color: blue; font-size: 16px; font-weight: bold;",
+ );
+ console.log(
+ `%cTotal: ${results.total} | Passed: ${results.passed} | Failed: ${results.failed}`,
+ results.failed === 0
+ ? "color: green; font-weight: bold;"
+ : "color: red; font-weight: bold;",
+ );
+})();
diff --git a/packages/chrome-extension-mock/runtime.ts b/packages/chrome-extension-mock/runtime.ts
index 909a545fd..322543926 100644
--- a/packages/chrome-extension-mock/runtime.ts
+++ b/packages/chrome-extension-mock/runtime.ts
@@ -65,6 +65,8 @@ export default class Runtime {
return port;
}
+ __mockGetURLToExtensionTest: boolean = false;
+
getURL(_path: string) {
// implemented with vitest
}
diff --git a/packages/message/types.ts b/packages/message/types.ts
index 23cd59aaf..90b6d6563 100644
--- a/packages/message/types.ts
+++ b/packages/message/types.ts
@@ -46,6 +46,10 @@ export interface MessageSend {
sendMessage(data: TMessage): Promise;
}
+export interface IOffscreenSend extends MessageSend {
+ init(): Promise | void;
+}
+
export interface MessageConnect {
onMessage(callback: (data: TMessage) => void): void;
sendMessage(data: TMessage): void;
diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts
index baedbbe30..6b507cf4e 100644
--- a/packages/message/window_message.ts
+++ b/packages/message/window_message.ts
@@ -1,4 +1,4 @@
-import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage } from "./types";
+import type { Message, MessageConnect, IOffscreenSend, RuntimeMessageSender, TMessage } from "./types";
import { uuidv4 } from "@App/pkg/utils/uuid";
import EventEmitter from "eventemitter3";
@@ -163,7 +163,7 @@ export class WindowMessageConnect implements MessageConnect {
// service_worker和offscreen同时监听消息,会导致消息被两边同时接收,但是返回结果时会产生问题,导致报错
// 不进行监听的话又无法从service_worker主动发送消息
// 所以service_worker与offscreen使用ServiceWorker的方式进行通信
-export class ServiceWorkerMessageSend implements MessageSend {
+export class ServiceWorkerMessageSend implements IOffscreenSend {
EE = new EventEmitter();
private target: PostMessage | undefined = undefined;
diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts
index 0d90a5d12..701cc338c 100644
--- a/src/app/service/content/global.ts
+++ b/src/app/service/content/global.ts
@@ -38,3 +38,20 @@ export const customClone = (o: any) => {
console.error("customClone failed");
return undefined;
};
+
+/** is Firefox browser? */
+//@ts-ignore
+const bFirefox = typeof mozInnerScreenX === "number";
+/** is Firefox browser and running in isolated environment? */
+const bFirefoxIsolatedScript =
+ //@ts-ignore
+ bFirefox && typeof wrappedJSObject !== "undefined" && Object.hasOwn(window, "wrappedJSObject");
+/** is Firefox browser and running in isolated userscript API environment? */
+const isFFContent =
+ //@ts-ignore
+ bFirefoxIsolatedScript && typeof browser === "object" && typeof browser?.runtime?.sendMessage === "function";
+
+/** Required for Firefox */
+export const localizeObject = isFFContent
+ ? (script: T): T => customClone(script)
+ : (script: T): T => script;
diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts
index 6e9f2441a..37f420f14 100644
--- a/src/app/service/content/script_executor.ts
+++ b/src/app/service/content/script_executor.ts
@@ -9,11 +9,12 @@ import { DefinedFlags } from "../service_worker/runtime.consts";
import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common";
import { isUrlExcluded } from "@App/pkg/utils/match";
import type { ScriptEnvTag } from "@Packages/message/consts";
+import { localizeObject } from "./global";
export type ExecScriptEntry = {
scriptLoadInfo: TScriptInfo;
scriptFlag: string;
- envInfo: any;
+ envInfo: GMInfoEnv;
scriptFunc: any;
};
@@ -138,7 +139,9 @@ export class ScriptExecutor {
}
execScriptEntry(scriptEntry: ExecScriptEntry) {
- const { scriptLoadInfo, scriptFunc, envInfo } = scriptEntry;
+ const { scriptFunc } = scriptEntry;
+ const envInfo = localizeObject(scriptEntry.envInfo);
+ const scriptLoadInfo = localizeObject(scriptEntry.scriptLoadInfo);
const execScript = new ExecScript(scriptLoadInfo, {
envPrefix: "scripting",
diff --git a/src/app/service/offscreen/base.ts b/src/app/service/offscreen/base.ts
new file mode 100644
index 000000000..177418735
--- /dev/null
+++ b/src/app/service/offscreen/base.ts
@@ -0,0 +1,68 @@
+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 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 { VSCodeConnect } from "./vscode-connect";
+import { makeBlobURL } from "@App/pkg/utils/utils";
+
+// offscreen环境的管理器
+export class BackgroundEnvManagerBase {
+ private readonly messageQueue = new MessageQueue();
+
+ constructor(
+ private readonly extMsgSender: MessageSend,
+ private readonly windowMessage: WindowMessage,
+ private readonly offscreenServer: Server,
+ private readonly serviceWorker: ServiceWorkerClient
+ ) {}
+
+ logger(data: Logger) {
+ // 发送日志消息
+ this.sendMessageToServiceWorker({
+ action: "logger",
+ data,
+ });
+ }
+
+ preparationSandbox() {
+ // 通知初始化好环境了
+ this.serviceWorker.preparationOffscreen();
+ }
+
+ sendMessageToServiceWorker(data: { action: string; data: any }) {
+ return sendMessage(this.extMsgSender, `serviceWorker/${data.action}`, data.data);
+ }
+
+ async initManager() {
+ // 监听消息
+ this.offscreenServer.on("logger", this.logger.bind(this));
+ this.offscreenServer.on("preparationSandbox", this.preparationSandbox.bind(this));
+ this.offscreenServer.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this));
+ const script = new ScriptService(
+ this.offscreenServer.group("script"),
+ this.extMsgSender,
+ this.windowMessage,
+ this.messageQueue
+ );
+ script.init();
+ // 转发从sandbox来的gm api请求
+ forwardMessage("serviceWorker", "runtime/gmApi", this.offscreenServer, this.extMsgSender);
+ // 转发valueUpdate与emitEvent
+ forwardMessage("sandbox", "runtime/valueUpdate", this.offscreenServer, this.windowMessage);
+ forwardMessage("sandbox", "runtime/emitEvent", this.offscreenServer, this.windowMessage);
+
+ const gmApi = new GMApi(this.offscreenServer.group("gmApi"));
+ gmApi.init();
+ const vscodeConnect = new VSCodeConnect(this.offscreenServer.group("vscodeConnect"), this.extMsgSender);
+ vscodeConnect.init();
+
+ this.offscreenServer.on("createObjectURL", async (params: { blob: Blob; persistence: boolean }) => {
+ return makeBlobURL(params) as string;
+ });
+ }
+}
diff --git a/src/app/service/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts
new file mode 100644
index 000000000..365929bd2
--- /dev/null
+++ b/src/app/service/offscreen/event_page_manager.ts
@@ -0,0 +1,126 @@
+import { Server } from "@Packages/message/server";
+import type {
+ IOffscreenSend,
+ Message,
+ MessageConnect,
+ MessageSend,
+ RuntimeMessageSender,
+ TMessage,
+} from "@Packages/message/types";
+import { WindowMessage } from "@Packages/message/window_message";
+import EventEmitter from "eventemitter3";
+import { ServiceWorkerClient } from "../service_worker/client";
+import { BackgroundEnvManagerBase } from "./base";
+
+class InProcessMessageConnect implements MessageConnect {
+ private messages = new EventEmitter();
+
+ private disconnects = new EventEmitter();
+
+ private disconnected = false;
+
+ peer?: InProcessMessageConnect;
+
+ onMessage(callback: (data: TMessage) => void): void {
+ this.messages.on("message", callback);
+ }
+
+ sendMessage(data: TMessage): void {
+ if (!this.disconnected) {
+ this.peer?.messages.emit("message", data);
+ }
+ }
+
+ disconnect(): void {
+ if (this.disconnected) {
+ return;
+ }
+ this.disconnected = true;
+ this.disconnects.emit("disconnect");
+ if (this.peer && !this.peer.disconnected) {
+ this.peer.disconnected = true;
+ this.peer.disconnects.emit("disconnect");
+ }
+ }
+
+ onDisconnect(callback: () => void): void {
+ this.disconnects.on("disconnect", callback);
+ }
+}
+
+class InProcessMessage implements Message, MessageSend {
+ private events = new EventEmitter();
+
+ connect(data: TMessage): Promise {
+ const client = new InProcessMessageConnect();
+ const server = new InProcessMessageConnect();
+ client.peer = server;
+ server.peer = client;
+ queueMicrotask(() => {
+ this.events.emit("connect", data, server);
+ });
+ return Promise.resolve(client);
+ }
+
+ sendMessage(data: TMessage): Promise {
+ return new Promise((resolve) => {
+ this.events.emit("message", data, resolve, {} as RuntimeMessageSender);
+ });
+ }
+
+ onConnect(callback: (data: TMessage, con: MessageConnect) => void): void {
+ this.events.on("connect", callback);
+ }
+
+ onMessage(
+ callback: (data: TMessage, sendResponse: (data: any) => void, sender: RuntimeMessageSender) => boolean | void
+ ): void {
+ this.events.on("message", callback);
+ }
+}
+
+export class EventPageOffscreenManager extends BackgroundEnvManagerBase implements IOffscreenSend {
+ private readonly message: InProcessMessage;
+ private initialized = false;
+
+ constructor(extMsgSender: MessageSend) {
+ if (typeof document !== "object" || !document?.documentElement) {
+ throw new Error("EventPageOffscreenManager requires a DOM-capable Firefox MV3 Event Page.");
+ }
+
+ const sandbox = document.createElement("iframe");
+ sandbox.src = chrome.runtime.getURL("src/sandbox.html");
+ sandbox.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);
+ const offscreenServer = new Server("offscreen", [message, windowMessage]);
+ const serviceWorker = new ServiceWorkerClient(extMsgSender);
+
+ super(extMsgSender, windowMessage, offscreenServer, serviceWorker);
+ this.message = message;
+ }
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+ return super.initManager();
+ }
+
+ connect(data: TMessage): Promise {
+ return this.message.connect(data);
+ }
+
+ sendMessage(data: TMessage): Promise {
+ return this.message.sendMessage(data);
+ }
+}
diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts
index dcdc31303..5cc2a554c 100644
--- a/src/app/service/offscreen/gm_api.ts
+++ b/src/app/service/offscreen/gm_api.ts
@@ -6,7 +6,7 @@ import { mightPrepareSetClipboard, setClipboard } from "../service_worker/clipbo
export const nativePageXHR = async (details: GMSend.XHRDetails, sender: IGetSender) => {
const con = sender.getConnect(); // con can be undefined
if (!con) throw new Error("offscreen xmlHttpRequest: Connection is undefined");
- const bgGmXhr = new BgGMXhr(details, { statusCode: 0, finalUrl: "", responseHeaders: "" }, con);
+ const bgGmXhr = new BgGMXhr(details, { statusCode: 0, finalUrl: "", responseHeaders: "" }, con, null, "");
bgGmXhr.do();
};
diff --git a/src/app/service/offscreen/index.ts b/src/app/service/offscreen/index.ts
index a3a22328d..870e8e27e 100644
--- a/src/app/service/offscreen/index.ts
+++ b/src/app/service/offscreen/index.ts
@@ -1,73 +1,15 @@
-import { forwardMessage, Server } from "@Packages/message/server";
+import { 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 { WindowMessage } from "@Packages/message/window_message";
import { ServiceWorkerClient } from "../service_worker/client";
-import { sendMessage } from "@Packages/message/client";
-import GMApi from "./gm_api";
-import { MessageQueue } from "@Packages/message/message_queue";
-import { VSCodeConnect } from "./vscode-connect";
-import { makeBlobURL } from "@App/pkg/utils/utils";
+import { BackgroundEnvManagerBase } from "./base";
// offscreen环境的管理器
-export class OffscreenManager {
- private windowMessage: WindowMessage;
-
- private windowServer: Server;
-
- private messageQueue = new MessageQueue();
-
- private serviceWorker: ServiceWorkerClient;
-
- constructor(private extMsgSender: MessageSend) {
- this.windowMessage = new WindowMessage(window, sandbox, true);
- this.windowServer = new Server("offscreen", this.windowMessage);
- this.serviceWorker = new ServiceWorkerClient(this.extMsgSender);
- }
-
- logger(data: Logger) {
- // 发送日志消息
- this.sendMessageToServiceWorker({
- action: "logger",
- data,
- });
- }
-
- preparationSandbox() {
- // 通知初始化好环境了
- this.serviceWorker.preparationOffscreen();
- }
-
- sendMessageToServiceWorker(data: { action: string; data: any }) {
- return sendMessage(this.extMsgSender, `serviceWorker/${data.action}`, data.data);
- }
-
- async initManager() {
- // 监听消息
- this.windowServer.on("logger", this.logger.bind(this));
- this.windowServer.on("preparationSandbox", this.preparationSandbox.bind(this));
- this.windowServer.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this));
- const script = new ScriptService(
- this.windowServer.group("script"),
- this.extMsgSender,
- this.windowMessage,
- this.messageQueue
- );
- script.init();
- // 转发从sandbox来的gm api请求
- forwardMessage("serviceWorker", "runtime/gmApi", this.windowServer, this.extMsgSender);
- // 转发valueUpdate与emitEvent
- forwardMessage("sandbox", "runtime/valueUpdate", this.windowServer, this.windowMessage);
- forwardMessage("sandbox", "runtime/emitEvent", this.windowServer, this.windowMessage);
-
- const gmApi = new GMApi(this.windowServer.group("gmApi"));
- gmApi.init();
- const vscodeConnect = new VSCodeConnect(this.windowServer.group("vscodeConnect"), this.extMsgSender);
- vscodeConnect.init();
-
- this.windowServer.on("createObjectURL", async (params: { blob: Blob; persistence: boolean }) => {
- return makeBlobURL(params) as string;
- });
+export class OffscreenManager extends BackgroundEnvManagerBase {
+ constructor(extMsgSender: MessageSend) {
+ const windowMessage = new WindowMessage(window, sandbox, true);
+ const windowServer = new Server("offscreen", windowMessage);
+ const serviceWorker = new ServiceWorkerClient(extMsgSender);
+ super(extMsgSender, windowMessage, windowServer, serviceWorker);
}
}
diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts
index dd08559b0..d10a7f903 100644
--- a/src/app/service/service_worker/gm_api/gm_api.ts
+++ b/src/app/service/service_worker/gm_api/gm_api.ts
@@ -56,6 +56,7 @@ import { nativePageWindowOpen } from "../../offscreen/gm_api";
import { nextSessionRuleId, removeSessionRuleIdEntry } from "./dnr_id_controller";
import type { DownloadCallback } from "../download";
import { detachDownloadCallback, startDownload } from "../download";
+import { isRequestInitiatorOriginMatched, gmXhrRequestLinker, type IWebRequestDetails } from "./mv3_utils";
let generatedUniqueMarkerIDs = "";
let generatedUniqueMarkerIDWhen = "";
@@ -629,12 +630,7 @@ export default class GMApi {
const headers = params.headers || (params.headers = {});
const { anonymous, cookie } = params;
- // HTTP/1.1 and HTTP/2
- // https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2
- // https://datatracker.ietf.org/doc/html/rfc6648
- // All header names in HTTP/2 are lower case, and CF will convert if needed.
- // All headers comparisons in HTTP/1.1 should be case insensitive.
- headers["x-sc-request-marker"] = `${markerID}`;
+ gmXhrRequestLinker.prepareRequest(params, headers, markerID);
// 关联 reqID 方法
// 1) 尝试在 onBeforeRequest 进行关连
@@ -935,7 +931,7 @@ export default class GMApi {
strategy = new GMXhrXhrStrategy(resultParam);
}
if (strategy) {
- const bgGmXhr = new BgGMXhr(details, resultParam, msgConn, strategy);
+ const bgGmXhr = new BgGMXhr(details, resultParam, msgConn, strategy, markerID);
bgGmXhr.onLoaded(loadendCleanUp);
bgGmXhr.do();
} else {
@@ -1462,6 +1458,11 @@ export default class GMApi {
// 处理GM_xmlhttpRequest请求
handlerGmXhr() {
+ gmXhrRequestLinker.setup({ cleanupOnAPIError });
+ const currentOrigin: string = new URL(chrome.runtime.getURL("/")).origin;
+ const isInitiatedBySC = (details: IWebRequestDetails) => {
+ return details.tabId === -1 && isRequestInitiatorOriginMatched(details, currentOrigin);
+ };
chrome.webRequest.onBeforeRedirect.addListener(
(details) => {
const lastError = chrome.runtime.lastError;
@@ -1471,7 +1472,7 @@ export default class GMApi {
cleanupOnAPIError(details?.requestId);
return undefined;
}
- if (details.tabId === -1) {
+ if (isInitiatedBySC(details)) {
const markerID = scXhrRequests.get(details.requestId);
if (markerID) {
redirectedUrls.set(markerID, details.redirectUrl);
@@ -1500,7 +1501,7 @@ export default class GMApi {
cleanupOnAPIError(details?.requestId);
return undefined;
}
- if (details.tabId === -1) {
+ if (isInitiatedBySC(details)) {
const markerID = scXhrRequests.get(details.requestId);
if (!markerID) return;
nwErrorResults.set(markerID, details.error);
@@ -1566,7 +1567,7 @@ export default class GMApi {
cleanupOnAPIError(details?.requestId);
return undefined;
}
- if (details.tabId === -1) {
+ if (isInitiatedBySC(details)) {
const reqId = details.requestId;
const requestHeaders = details.requestHeaders;
if (requestHeaders) {
@@ -1601,7 +1602,7 @@ export default class GMApi {
cleanupOnAPIError(details?.requestId);
return undefined;
}
- if (details.tabId === -1) {
+ if (isInitiatedBySC(details)) {
const reqId = details.requestId;
const markerID = scXhrRequests.get(reqId);
@@ -1688,7 +1689,7 @@ export default class GMApi {
cleanupOnAPIError(details?.requestId);
return undefined;
}
- if (details.tabId === -1) {
+ if (isInitiatedBySC(details)) {
const reqId = details.requestId;
const markerID = scXhrRequests.get(reqId);
@@ -1709,37 +1710,6 @@ export default class GMApi {
},
respOpt
);
-
- const ruleId = 999;
- const rule = {
- id: ruleId,
- action: {
- type: "modifyHeaders",
- requestHeaders: [
- {
- header: "x-sc-request-marker",
- operation: "remove",
- },
- ] satisfies chrome.declarativeNetRequest.ModifyHeaderInfo[],
- },
- priority: 1,
- condition: {
- resourceTypes: ["xmlhttprequest"],
- tabIds: [chrome.tabs.TAB_ID_NONE], // 只限于后台 service_worker / offscreen
- },
- } as chrome.declarativeNetRequest.Rule;
- chrome.declarativeNetRequest.updateSessionRules(
- {
- removeRuleIds: [ruleId],
- addRules: [rule],
- },
- () => {
- const lastError = chrome.runtime.lastError;
- if (lastError) {
- console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError);
- }
- }
- );
}
start() {
diff --git a/src/app/service/service_worker/gm_api/gm_xhr.ts b/src/app/service/service_worker/gm_api/gm_xhr.ts
index 24806c509..5a3850c37 100644
--- a/src/app/service/service_worker/gm_api/gm_xhr.ts
+++ b/src/app/service/service_worker/gm_api/gm_xhr.ts
@@ -54,7 +54,11 @@ export class SWRequestResultParams {
}
get finalUrl() {
- this.resultParamFinalUrl = redirectedUrls.get(this.markerID) || "";
+ const markerID = this.markerID;
+ if (!markerID) {
+ console.error("[gm_xhr.ts] SWRequestResultParams::finalUrl", "no markerID");
+ }
+ this.resultParamFinalUrl = redirectedUrls.get(markerID) || "";
return this.resultParamFinalUrl;
}
}
diff --git a/src/app/service/service_worker/gm_api/mv3_utils.test.ts b/src/app/service/service_worker/gm_api/mv3_utils.test.ts
new file mode 100644
index 000000000..976b7b12d
--- /dev/null
+++ b/src/app/service/service_worker/gm_api/mv3_utils.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from "vitest";
+import { ChromiumHeaderMarkerLinker, FirefoxWebRequestLinker, normalizeBackgroundRequestUrl } from "./mv3_utils";
+import { scXhrRequests } from "./gm_xhr";
+
+describe("GM XHR request linker", () => {
+ it("keep query strings when normalizing Firefox background request URLs", () => {
+ expect(normalizeBackgroundRequestUrl("https://test-example.com/api/search")).toBe(
+ "https://test-example.com/api/search"
+ );
+ expect(normalizeBackgroundRequestUrl("https://user:@example.com/api/search")).toBe(
+ "https://example.com/api/search"
+ );
+ expect(normalizeBackgroundRequestUrl("https://user:pass@example.com/api/search")).toBe(
+ "https://example.com/api/search"
+ );
+ expect(normalizeBackgroundRequestUrl("https://example.com/api/search?q=one&a=1")).toBe(
+ "https://example.com/api/search?q=one&a=1"
+ );
+ expect(normalizeBackgroundRequestUrl("https://user:@example.com/api/search?a=2&q=two")).toBe(
+ "https://example.com/api/search?a=2&q=two"
+ );
+ expect(normalizeBackgroundRequestUrl("https://user:pass@example.com/api/search?q=one&a=3")).toBe(
+ "https://example.com/api/search?q=one&a=3"
+ );
+ expect(normalizeBackgroundRequestUrl("https://user:pass@example.com/api/search?a=4&q=two")).toBe(
+ "https://example.com/api/search?a=4&q=two"
+ );
+ });
+
+ it("adds Chromium marker header before the request is sent", () => {
+ const linker = new ChromiumHeaderMarkerLinker();
+ const headers: Record = {};
+
+ linker.prepareRequest({ url: "https://example.com/" } as GMSend.XHRDetails, headers, "MARKER::abc");
+
+ expect(headers["x-sc-request-marker"]).toBe("MARKER::abc");
+ });
+
+ it("installs Chromium DNR cleanup rule for the temporary marker header", async () => {
+ const linker = new ChromiumHeaderMarkerLinker();
+
+ linker.setup({ cleanupOnAPIError: () => undefined });
+
+ const rules = await chrome.declarativeNetRequest.getSessionRules();
+ expect(rules).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 999,
+ action: expect.objectContaining({
+ requestHeaders: [
+ {
+ header: "x-sc-request-marker",
+ operation: "remove",
+ },
+ ],
+ }),
+ }),
+ ])
+ );
+ });
+
+ it("delegates Chromium send without extra request matching", () => {
+ const linker = new ChromiumHeaderMarkerLinker();
+ const xhr = {
+ send: vi.fn().mockReturnValue("sent"),
+ } as unknown as XMLHttpRequest;
+
+ const result = linker.send(xhr, "body", { markerID: "MARKER::abc", url: "https://example.com/" });
+
+ expect(result).toBe("sent");
+ expect(xhr.send).toHaveBeenCalledWith("body");
+ });
+
+ it("links Firefox requestId to markerID via webRequest.onBeforeRequest", async () => {
+ //@ts-ignore
+ chrome.runtime.__mockGetURLToExtensionTest = true;
+ const RID_1 = "9470";
+ const documentUrl = chrome.runtime.getURL("/_generated_background_page.html");
+ try {
+ const linker = new FirefoxWebRequestLinker();
+ linker.setup({ cleanupOnAPIError: () => undefined });
+
+ const xhr = { send: vi.fn() } as unknown as XMLHttpRequest;
+ const markerID = "MARKER::abc";
+ const url = "https://example.com/api/search?q=one";
+ const sendPromise = linker.send(xhr, null, { markerID, url });
+
+ // trigger mock webRequest event
+ const wbr = chrome.webRequest.onBeforeRequest as any;
+ wbr.EE.emit("onBeforeRequest", {
+ tabId: -1,
+ requestId: RID_1,
+ url,
+ documentUrl: documentUrl,
+ originUrl: documentUrl,
+ timeStamp: Date.now(),
+ });
+
+ await sendPromise;
+
+ expect(scXhrRequests.get(RID_1)).toBe(markerID);
+ expect(scXhrRequests.get(markerID)).toBe(RID_1);
+ } finally {
+ //@ts-ignore
+ chrome.runtime.__mockGetURLToExtensionTest = false;
+ }
+ });
+});
diff --git a/src/app/service/service_worker/gm_api/mv3_utils.ts b/src/app/service/service_worker/gm_api/mv3_utils.ts
new file mode 100644
index 000000000..a545f4e85
--- /dev/null
+++ b/src/app/service/service_worker/gm_api/mv3_utils.ts
@@ -0,0 +1,208 @@
+import { stackAsyncTask } from "@App/pkg/utils/async_queue";
+import { isFirefox } from "@App/pkg/utils/utils";
+import { type FetchXHR } from "@App/pkg/utils/xhr/fetch_xhr";
+import { scXhrRequests } from "./gm_xhr";
+
+const bFirefox = isFirefox();
+type TbgMarkerMapEntry = {
+ markerID: string;
+ reqId?: string;
+ r?: any;
+ url: string;
+ resolvePromise: (r: any) => void;
+};
+const bgMarkerMap = new Map();
+export const normalizeBackgroundRequestUrl = (url: string) => {
+ const u = new URL(url);
+ // input "https://user:passwd@httpbun.com/basic-auth/user/passwd?q=1&r=2"
+ // output "https://httpbun.com/basic-auth/user/passwd?q=1&r=2"
+ return `${u.origin}${u.pathname}${u.search}`;
+};
+
+export type IWebRequestDetails = {
+ /** The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (`type` is `main_frame` or `sub_frame`), `frameId` indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab. */
+ frameId: number;
+ /** Standard HTTP method. */
+ method: string;
+ /** ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists. */
+ parentFrameId: number;
+ /** The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request. */
+ requestId: string;
+ /** The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab. */
+ tabId: number;
+ /** The time when this signal is triggered, in milliseconds since the epoch. */
+ timeStamp: number;
+ /** How the requested resource will be used. */
+ type: `${chrome.declarativeNetRequest.ResourceType}`;
+ url: string;
+ /** [Chrome 63+] The origin where the request was initiated. This does not change through redirects. If this is an opaque origin, the string 'null' will be used. */
+ initiator?: string; // chrome MV3
+ /** [Firefox 54+] URL of the document in which the resource will be loaded. */
+ documentUrl?: string; // firefox MV3
+ /** [Firefox 48+] URL of the resource which triggered the request. */
+ originUrl?: string; // firefox MV3
+};
+
+/** Minimum Requirements: Chrome 63+ or Firefox 54+ */
+export const isRequestInitiatorOriginMatched = (request: IWebRequestDetails, targetOrigin: string) => {
+ let url: string | undefined;
+ try {
+ if (typeof request.initiator === "string") {
+ url = request.initiator;
+ } else if (typeof request.documentUrl === "string" && typeof request.originUrl === "string") {
+ url = request.originUrl;
+ }
+ if (url && url.length > 8 && targetOrigin && targetOrigin.length > 8) {
+ // avoid "null"; requires something like "abc://def"
+ return url.startsWith(targetOrigin);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ return false;
+};
+
+type SetupParams = {
+ cleanupOnAPIError: (requestId: string) => void;
+};
+
+type SendContext = {
+ markerID: string;
+ url: string;
+};
+
+export interface GmXhrRequestLinker {
+ setup(params: SetupParams): void;
+ prepareRequest(details: GMSend.XHRDetails, headers: { [key: string]: string }, markerID: string): void;
+ send(
+ baseXHR: FetchXHR | XMLHttpRequest,
+ data: XMLHttpRequestBodyInit | null | undefined,
+ context: SendContext
+ ): Promise | any;
+}
+
+const resolver = (o: TbgMarkerMapEntry) => {
+ const resolvePromise = o.resolvePromise;
+ const result = o.r;
+ bgMarkerMap.delete(o.url);
+ bgMarkerMap.delete(o.markerID);
+ scXhrRequests.set(o.reqId!, o.markerID);
+ scXhrRequests.set(o.markerID, o.reqId!);
+ o.r = null;
+ resolvePromise(result);
+};
+
+export class FirefoxWebRequestLinker implements GmXhrRequestLinker {
+ private readonly currentOrigin: string = new URL(chrome.runtime.getURL("/")).origin;
+ setup({ cleanupOnAPIError }: SetupParams) {
+ chrome.webRequest.onBeforeRequest.addListener(
+ (details) => {
+ const lastError = chrome.runtime.lastError;
+ if (lastError) {
+ console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRequest:", lastError);
+ // webRequest API 出错不进行后续处理
+ cleanupOnAPIError(details?.requestId);
+ return undefined;
+ }
+ if (details.tabId === -1 && isRequestInitiatorOriginMatched(details, this.currentOrigin)) {
+ const wURL = normalizeBackgroundRequestUrl(details.url);
+ const o = bgMarkerMap.get(wURL);
+ if (o) {
+ bgMarkerMap.delete(wURL);
+ o.reqId = details.requestId;
+ resolver(o);
+ } else if (scXhrRequests.has(details.requestId)) {
+ // redirection to new url
+ } else {
+ console.error(`onBeforeRequest: No marker ID record for ${wURL}`);
+ }
+ }
+ },
+ {
+ urls: [""],
+ types: ["xmlhttprequest"],
+ tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen
+ }
+ );
+ }
+
+ prepareRequest(_details: GMSend.XHRDetails, _headers: { [key: string]: string }, _markerID: string) {
+ // Firefox links background requests through webRequest requestId capture.
+ }
+
+ async send(
+ baseXHR: FetchXHR | XMLHttpRequest,
+ data: XMLHttpRequestBodyInit | null | undefined,
+ { markerID, url }: SendContext
+ ) {
+ // Send data (if any)
+ if (!markerID) return baseXHR.send(data);
+ const fn = (resolve: any, _reject: any) => {
+ const wURL = normalizeBackgroundRequestUrl(url);
+ const o: TbgMarkerMapEntry = {
+ url: wURL,
+ markerID,
+ resolvePromise: resolve,
+ };
+ bgMarkerMap.delete(markerID);
+ bgMarkerMap.delete(wURL);
+ bgMarkerMap.set(markerID, o);
+ bgMarkerMap.set(wURL, o);
+ // Send data (if any)
+ o.r = baseXHR.send(data);
+ };
+ return await stackAsyncTask("bg_gm_xhr_queue", () => new Promise(fn));
+ }
+}
+
+export class ChromiumHeaderMarkerLinker implements GmXhrRequestLinker {
+ setup(_params: SetupParams) {
+ const ruleId = 999;
+ const rule = {
+ id: ruleId,
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ {
+ header: "x-sc-request-marker",
+ operation: "remove",
+ },
+ ] satisfies chrome.declarativeNetRequest.ModifyHeaderInfo[],
+ },
+ priority: 1,
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ tabIds: [chrome.tabs.TAB_ID_NONE], // 只限于后台 service_worker / offscreen
+ },
+ } as chrome.declarativeNetRequest.Rule;
+ chrome.declarativeNetRequest.updateSessionRules(
+ {
+ removeRuleIds: [ruleId],
+ addRules: [rule],
+ },
+ () => {
+ const lastError = chrome.runtime.lastError;
+ if (lastError) {
+ console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError);
+ }
+ }
+ );
+ }
+
+ prepareRequest(_details: GMSend.XHRDetails, headers: { [key: string]: string }, markerID: string) {
+ // HTTP/1.1 and HTTP/2
+ // https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2
+ // https://datatracker.ietf.org/doc/html/rfc6648
+ // All header names in HTTP/2 are lower case, and CF will convert if needed.
+ // All headers comparisons in HTTP/1.1 should be case insensitive.
+ headers["x-sc-request-marker"] = `${markerID}`;
+ }
+
+ send(baseXHR: FetchXHR | XMLHttpRequest, data: XMLHttpRequestBodyInit | null | undefined, _context?: SendContext) {
+ return baseXHR.send(data);
+ }
+}
+
+export const gmXhrRequestLinker: GmXhrRequestLinker = bFirefox
+ ? new FirefoxWebRequestLinker()
+ : new ChromiumHeaderMarkerLinker();
diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts
index 51f381283..f43c56f12 100644
--- a/src/app/service/service_worker/index.ts
+++ b/src/app/service/service_worker/index.ts
@@ -5,7 +5,7 @@ import { ScriptService } from "./script";
import { ResourceService } from "./resource";
import { ValueService } from "./value";
import { RuntimeService } from "./runtime";
-import { type ServiceWorkerMessageSend } from "@Packages/message/window_message";
+import { type IOffscreenSend } from "@Packages/message/types";
import { PopupService } from "./popup";
import { SystemConfig } from "@App/pkg/config/config";
import { SynchronizeService } from "./synchronize";
@@ -27,7 +27,7 @@ export default class ServiceWorkerManager {
constructor(
private api: Server,
private mq: IMessageQueue,
- private sender: ServiceWorkerMessageSend
+ private offscreenSend: IOffscreenSend
) {}
logger(data: Logger) {
@@ -40,10 +40,10 @@ export default class ServiceWorkerManager {
this.api.on("logger", this.logger.bind(this));
this.api.on("preparationOffscreen", async () => {
// 准备好环境
- await this.sender.init();
+ await this.offscreenSend.init();
this.mq.emit("preparationOffscreen", {});
});
- this.sender.init();
+ this.offscreenSend.init();
const faviconDAO = new FaviconDAO();
@@ -67,7 +67,7 @@ export default class ServiceWorkerManager {
const runtime = new RuntimeService(
systemConfig,
this.api.group("runtime"),
- this.sender,
+ this.offscreenSend,
this.mq,
value,
script,
@@ -80,7 +80,7 @@ export default class ServiceWorkerManager {
popup.init();
value.init(runtime, popup);
const synchronize = new SynchronizeService(
- this.sender,
+ this.offscreenSend,
this.api.group("synchronize"),
script,
value,
@@ -95,7 +95,7 @@ export default class ServiceWorkerManager {
const system = new SystemService(
systemConfig,
this.api.group("system"),
- this.sender,
+ this.offscreenSend,
this.mq,
scriptDAO,
faviconDAO
diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts
index 25cf36d93..e497e662f 100644
--- a/src/pkg/utils/xhr/bg_gm_xhr.ts
+++ b/src/pkg/utils/xhr/bg_gm_xhr.ts
@@ -1,10 +1,11 @@
-import type { GMXhrStrategy } from "@App/app/service/service_worker/gm_api/gm_xhr";
+import { type GMXhrStrategy } from "@App/app/service/service_worker/gm_api/gm_xhr";
import { stackAsyncTask } from "@App/pkg/utils/async_queue";
import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/datatype";
import type { MessageConnect, TMessageCommAction } from "@Packages/message/types";
import { dataDecode } from "./xhr_data";
import { FetchXHR } from "./fetch_xhr";
import { normalizeResponseHeaders } from "../utils";
+import { gmXhrRequestLinker } from "@App/app/service/service_worker/gm_api/mv3_utils";
export type RequestResultParams = {
statusCode: number;
@@ -147,7 +148,8 @@ export class BgGMXhr {
private details: GMSend.XHRDetails,
private resultParams: RequestResultParams,
private msgConn: MessageConnect,
- private strategy?: GMXhrStrategy
+ private strategy: GMXhrStrategy | null,
+ private readonly markerID: string
) {
this.taskId = `${Date.now()}:${Math.random()}`;
this.isConnDisconnected = false;
@@ -476,8 +478,7 @@ export class BgGMXhr {
rawData = new Blob([rawData], { type: "application/octet-stream" });
}
- // Send data (if any)
- baseXHR.send(rawData ?? null);
+ await gmXhrRequestLinker.send(baseXHR, rawData ?? null, { markerID: this.markerID, url });
};
await prepareXHR();
diff --git a/src/pkg/utils/xhr/fetch_xhr.ts b/src/pkg/utils/xhr/fetch_xhr.ts
index 2635f957d..af78ae0ac 100644
--- a/src/pkg/utils/xhr/fetch_xhr.ts
+++ b/src/pkg/utils/xhr/fetch_xhr.ts
@@ -99,13 +99,13 @@ export class FetchXHR {
}
};
- async send(body?: BodyInit | null) {
+ private readonly sendAsync = async (resolve: (value: void | PromiseLike) => void) => {
if (this.readyState !== FetchXHR.OPENED || !this.method || !this.url) {
+ resolve();
throw new Error("Invalid state: call open() first.");
}
this.reqDone = false;
- this.body = body ?? null;
this.controller = new AbortController();
// Setup timeout if specified
@@ -124,6 +124,7 @@ export class FetchXHR {
this.extraOptsFn?.(opts);
this.onloadstart?.({ type: "loadstart" });
const res = await fetch(this.url, opts);
+ resolve();
// Update status + headers
this.status = res.status;
@@ -199,21 +200,16 @@ export class FetchXHR {
}
let customStatus = null;
- if (res.body === null) {
- if (res.type === "opaqueredirect") {
- customStatus = 301;
- } else {
- throw new Error("Response Body is null");
- }
- } else if (res.body !== null) {
+ const resBody = res.body;
+ if (resBody !== null) {
// Stream body for progress
let streamReader;
let streamReadable;
if (textDecoderStream) {
- streamReadable = res.body?.pipeThrough(textDecoderStream);
+ streamReadable = resBody?.pipeThrough(textDecoderStream);
if (!streamReadable) throw new Error("streamReadable is undefined.");
} else {
- streamReader = res.body?.getReader();
+ streamReader = resBody?.getReader();
if (!streamReader) throw new Error("streamReader is undefined.");
}
@@ -342,6 +338,17 @@ export class FetchXHR {
pushBuffer(data);
}
}
+ } else if (res.type === "opaqueredirect") {
+ customStatus = 301;
+ } else if (
+ this.method.toUpperCase() === "HEAD" ||
+ res.status === 204 ||
+ res.status === 205 ||
+ res.status === 304
+ ) {
+ // No body is expected for these responses.
+ } else {
+ throw new Error("Response Body is null");
}
this.status = customStatus || res.status;
@@ -358,6 +365,7 @@ export class FetchXHR {
this._emitReadyStateChange();
this.onload?.({ type: "load" });
} catch (err) {
+ resolve();
this.controller = null;
if (this.timeoutId != null) {
clearTimeout(this.timeoutId);
@@ -394,6 +402,14 @@ export class FetchXHR {
this.reqDone = true;
this.onloadend?.({ type: "loadend" });
}
+ };
+
+ send(body?: BodyInit | null) {
+ if (this.body !== null) {
+ throw new Error("Repeated Calls to send()");
+ }
+ this.body = body ?? null;
+ return new Promise(this.sendAsync);
}
abort() {
diff --git a/src/service_worker.ts b/src/service_worker.ts
index 62ff4d1c4..ce63936f7 100644
--- a/src/service_worker.ts
+++ b/src/service_worker.ts
@@ -6,6 +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 migrate, { migrateChromeStorage } from "./app/migrate";
import { cleanInvalidKeys } from "./app/repo/resource";
@@ -71,10 +72,24 @@ function main() {
loggerCore.logger().debug("service worker start");
const server = new Server("serviceWorker", message);
const messageQueue = new MessageQueue();
- const manager = new ServiceWorkerManager(server, messageQueue, new ServiceWorkerMessageSend());
- manager.initManager();
- // 初始化沙盒环境
- setupOffscreenDocument();
+ const hasOffscreenDocument = typeof chrome.offscreen?.createDocument === "function";
+ // Chrome needs a real offscreen document. Firefox MV3 uses EventPageOffscreenManager instead.
+ if (hasOffscreenDocument) {
+ const offscreen = new ServiceWorkerMessageSend();
+ const manager = new ServiceWorkerManager(server, messageQueue, offscreen);
+ manager.initManager();
+ setupOffscreenDocument();
+ } else {
+ const offscreen = new EventPageOffscreenManager(message);
+ 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);
+ }
}
main();
diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts
index e93770c32..451c51651 100644
--- a/tests/vitest.setup.ts
+++ b/tests/vitest.setup.ts
@@ -1,4 +1,5 @@
import chromeMock from "@Packages/chrome-extension-mock";
+import type Runtime from "@Packages/chrome-extension-mock/runtime";
import { initTestEnv } from "./utils";
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
@@ -40,7 +41,8 @@ vi.stubGlobal("chrome", chromeMock);
chromeMock.init();
initTestEnv();
-chromeMock.runtime.getURL = vi.fn().mockImplementation((path: string) => {
+chromeMock.runtime.getURL = vi.fn().mockImplementation(function (this: Runtime, path: string) {
+ if (this.__mockGetURLToExtensionTest) return `https://extension.test${path}`;
return `chrome-extension://${chrome.runtime.id}${path}`;
});