From 0c6d910ac25ec479f23c4ee59b665ef74e89a563 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 11 May 2026 13:02:48 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=E5=85=BC=E5=AE=B9=20Firefox=20MV3=20?= =?UTF-8?q?=E8=A7=84=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/gm_xhr_redirect_test.js | 244 ++++++++++++++++++ example/tests/gm_xhr_test.js | 219 +++++++++++++++- example/tests/window_message_test.js | 176 +++++++++++++ packages/message/types.ts | 4 + packages/message/window_message.ts | 4 +- src/app/service/offscreen/base.ts | 68 +++++ .../service/offscreen/event_page_manager.ts | 126 +++++++++ src/app/service/offscreen/gm_api.ts | 2 +- src/app/service/offscreen/index.ts | 74 +----- .../service/service_worker/gm_api/gm_api.ts | 56 +--- .../service/service_worker/gm_api/gm_xhr.ts | 6 +- .../service_worker/gm_api/mv3_utils.test.ts | 72 ++++++ .../service_worker/gm_api/mv3_utils.ts | 208 +++++++++++++++ src/app/service/service_worker/index.ts | 14 +- src/pkg/utils/xhr/bg_gm_xhr.ts | 9 +- src/pkg/utils/xhr/fetch_xhr.ts | 19 +- src/service_worker.ts | 23 +- 17 files changed, 1177 insertions(+), 147 deletions(-) create mode 100644 example/tests/gm_xhr_redirect_test.js create mode 100644 example/tests/window_message_test.js create mode 100644 src/app/service/offscreen/base.ts create mode 100644 src/app/service/offscreen/event_page_manager.ts create mode 100644 src/app/service/service_worker/gm_api/mv3_utils.test.ts create mode 100644 src/app/service/service_worker/gm_api/mv3_utils.ts 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/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/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/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..f4ece15c0 --- /dev/null +++ b/src/app/service/service_worker/gm_api/mv3_utils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { ChromiumHeaderMarkerLinker, normalizeBackgroundRequestUrl } from "./mv3_utils"; + +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"); + }); +}); 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..a1aa0d038 100644 --- a/src/pkg/utils/xhr/fetch_xhr.ts +++ b/src/pkg/utils/xhr/fetch_xhr.ts @@ -199,21 +199,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 +337,12 @@ export class FetchXHR { pushBuffer(data); } } + } else if (res.type === "opaqueredirect") { + customStatus = 301; + } else if (this.method.toUpperCase() === "HEAD") { + // for Firefox, HEAD request gives body null + } else { + throw new Error("Response Body is null"); } this.status = customStatus || res.status; 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(); From 6a5875bc7f2dcfb763aed180524ba5835b90ebed Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 12:11:36 +0900 Subject: [PATCH 02/13] example tests rename --- example/tests/early_inject_content_test.js | 6 +++--- example/tests/{early_test.js => early_inject_page_test.js} | 6 +++--- example/tests/gm_api_async_test.js | 2 +- example/tests/{gm_api_test.js => gm_api_sync_test.js} | 4 ++-- example/tests/inject_content_test.js | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) rename example/tests/{early_test.js => early_inject_page_test.js} (98%) rename example/tests/{gm_api_test.js => gm_api_sync_test.js} (99%) 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/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 From b8238dd0152abdaf281efa87bf552cdfc3a40171 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 12:59:36 +0900 Subject: [PATCH 03/13] fix `Error: Not allowed to define cross-origin object as property on [Object] or [Array] XrayWrapper` --- src/app/service/content/global.ts | 17 +++++++++++++++++ src/app/service/content/script_executor.ts | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) 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", From 3c7d122f9aabea3c24878c8a54d135a3a305ca98 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 13:08:40 +0900 Subject: [PATCH 04/13] fix e2e test --- e2e/gm-api.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index b6a023f44..4634bd53d 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -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 ); From ca6d6f91700b79f6e8e7d68847fa390000e106ae Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 14:52:19 +0900 Subject: [PATCH 05/13] add e2e WindowMessage Transport Test --- e2e/gm-api.spec.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 4634bd53d..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(); }); }); @@ -202,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); + }); }); From f5f07aeb8fff4a1f9cf5cc349500684354aba68c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 15:02:17 +0900 Subject: [PATCH 06/13] fetch_xhr: No body is expected for these responses. --- src/pkg/utils/xhr/fetch_xhr.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pkg/utils/xhr/fetch_xhr.ts b/src/pkg/utils/xhr/fetch_xhr.ts index a1aa0d038..4338d849d 100644 --- a/src/pkg/utils/xhr/fetch_xhr.ts +++ b/src/pkg/utils/xhr/fetch_xhr.ts @@ -339,8 +339,13 @@ export class FetchXHR { } } else if (res.type === "opaqueredirect") { customStatus = 301; - } else if (this.method.toUpperCase() === "HEAD") { - // for Firefox, HEAD request gives body null + } 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"); } From beff628d5de1ed3ea40d57345f453c5f3f48a80e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 15:04:27 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E7=94=A8=E4=BE=8B=E9=AA=8C=E8=AF=81=EF=BC=9A?= =?UTF-8?q?=E8=B0=83=E7=94=A8=20send()=20=E5=90=8E=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=20onBeforeRequest=20=E8=83=BD=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E5=86=99=E5=85=A5=20scXhrRequests=20=E6=98=A0?= =?UTF-8?q?=E5=B0=84=EF=BC=8C=E9=81=BF=E5=85=8D=E5=9B=9E=E5=BD=92=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=20Firefox=20=E5=85=B3=E8=81=94=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../service_worker/gm_api/mv3_utils.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 index f4ece15c0..570bdd103 100644 --- a/src/app/service/service_worker/gm_api/mv3_utils.test.ts +++ b/src/app/service/service_worker/gm_api/mv3_utils.test.ts @@ -69,4 +69,34 @@ describe("GM XHR request linker", () => { expect(result).toBe("sent"); expect(xhr.send).toHaveBeenCalledWith("body"); }); + + it("links Firefox requestId to markerID via webRequest.onBeforeRequest", async () => { + const { FirefoxWebRequestLinker } = await import("./mv3_utils"); + const { scXhrRequests } = await import("./gm_xhr"); + + scXhrRequests.clear(); + + 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, + initiator: `chrome-extension://${chrome.runtime.id}`, + timeStamp: Date.now(), + }); + + await sendPromise; + + expect(scXhrRequests.get("RID_1")).toBe(markerID); + expect(scXhrRequests.get(markerID)).toBe("RID_1"); + }); }); From 47eec61b75e12c58aef0bc8320dfa52e5d533bea Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 15:14:33 +0900 Subject: [PATCH 08/13] fix copilot rubbish code --- .../service_worker/gm_api/mv3_utils.test.ts | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) 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 index 570bdd103..79e67885a 100644 --- a/src/app/service/service_worker/gm_api/mv3_utils.test.ts +++ b/src/app/service/service_worker/gm_api/mv3_utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { ChromiumHeaderMarkerLinker, normalizeBackgroundRequestUrl } from "./mv3_utils"; +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", () => { @@ -71,32 +72,36 @@ describe("GM XHR request linker", () => { }); it("links Firefox requestId to markerID via webRequest.onBeforeRequest", async () => { - const { FirefoxWebRequestLinker } = await import("./mv3_utils"); - const { scXhrRequests } = await import("./gm_xhr"); - scXhrRequests.clear(); - - 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, - initiator: `chrome-extension://${chrome.runtime.id}`, - timeStamp: Date.now(), - }); - - await sendPromise; - - expect(scXhrRequests.get("RID_1")).toBe(markerID); - expect(scXhrRequests.get(markerID)).toBe("RID_1"); + const originalGetURL = chrome.runtime.getURL; + chrome.runtime.getURL = vi.fn((path: string) => `https://extension.test${path}`); + + 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: chrome.runtime.getURL("/"), + originUrl: chrome.runtime.getURL("/"), + timeStamp: Date.now(), + }); + + await sendPromise; + + expect(scXhrRequests.get("RID_1")).toBe(markerID); + expect(scXhrRequests.get(markerID)).toBe("RID_1"); + } finally { + chrome.runtime.getURL = originalGetURL; + } }); }); From 3456654244b74d23d826e3c84039ca2195772491 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 15:23:57 +0900 Subject: [PATCH 09/13] code fix --- packages/chrome-extension-mock/runtime.ts | 2 ++ .../service_worker/gm_api/mv3_utils.test.ts | 20 ++++++++++--------- tests/vitest.setup.ts | 4 +++- 3 files changed, 16 insertions(+), 10 deletions(-) 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/src/app/service/service_worker/gm_api/mv3_utils.test.ts b/src/app/service/service_worker/gm_api/mv3_utils.test.ts index 79e67885a..17b2cb97c 100644 --- a/src/app/service/service_worker/gm_api/mv3_utils.test.ts +++ b/src/app/service/service_worker/gm_api/mv3_utils.test.ts @@ -73,9 +73,10 @@ describe("GM XHR request linker", () => { it("links Firefox requestId to markerID via webRequest.onBeforeRequest", async () => { scXhrRequests.clear(); - const originalGetURL = chrome.runtime.getURL; - chrome.runtime.getURL = vi.fn((path: string) => `https://extension.test${path}`); - + //@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 }); @@ -89,19 +90,20 @@ describe("GM XHR request linker", () => { const wbr = chrome.webRequest.onBeforeRequest as any; wbr.EE.emit("onBeforeRequest", { tabId: -1, - requestId: "RID_1", + requestId: RID_1, url, - documentUrl: chrome.runtime.getURL("/"), - originUrl: chrome.runtime.getURL("/"), + documentUrl: documentUrl, + originUrl: documentUrl, timeStamp: Date.now(), }); await sendPromise; - expect(scXhrRequests.get("RID_1")).toBe(markerID); - expect(scXhrRequests.get(markerID)).toBe("RID_1"); + expect(scXhrRequests.get(RID_1)).toBe(markerID); + expect(scXhrRequests.get(markerID)).toBe(RID_1); } finally { - chrome.runtime.getURL = originalGetURL; + //@ts-ignore + chrome.runtime.__mockGetURLToExtensionTest = false; } }); }); 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}`; }); From f3f76aa193378325d46000719d51e697345d0a9e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 15:43:37 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20fetchXhr=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/xhr/fetch_xhr.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pkg/utils/xhr/fetch_xhr.ts b/src/pkg/utils/xhr/fetch_xhr.ts index 4338d849d..d24167c2e 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) { + async sendAsync(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; @@ -364,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); @@ -392,6 +394,7 @@ export class FetchXHR { this.onerror?.({ type: "error" }, (err || "Unknown Error") as Error | string); } } finally { + resolve(); this.controller = null; if (this.timeoutId != null) { clearTimeout(this.timeoutId); @@ -402,6 +405,14 @@ export class FetchXHR { } } + send(body?: BodyInit | null) { + if (this.body !== null) { + throw new Error("Repeated Calls to send()"); + } + this.body = body ?? null; + return new Promise((resolve) => this.sendAsync(resolve)); + } + abort() { this.isAborted = true; if (!this.reqDone) { From 29cc2f45c58ebfa52bfe6c6c9dc1339b85fa9925 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 15:47:56 +0900 Subject: [PATCH 11/13] code fix --- src/pkg/utils/xhr/fetch_xhr.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pkg/utils/xhr/fetch_xhr.ts b/src/pkg/utils/xhr/fetch_xhr.ts index d24167c2e..8d4278fc0 100644 --- a/src/pkg/utils/xhr/fetch_xhr.ts +++ b/src/pkg/utils/xhr/fetch_xhr.ts @@ -99,7 +99,7 @@ export class FetchXHR { } }; - async sendAsync(resolve: (value: void | PromiseLike) => void) { + 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."); @@ -403,14 +403,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((resolve) => this.sendAsync(resolve)); + return new Promise(this.sendAsync); } abort() { From 1bd5e98627e8d28182f5fa5434f3223ee4dd47ba Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 16:03:10 +0900 Subject: [PATCH 12/13] code fix --- src/app/service/service_worker/gm_api/mv3_utils.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index 17b2cb97c..976b7b12d 100644 --- a/src/app/service/service_worker/gm_api/mv3_utils.test.ts +++ b/src/app/service/service_worker/gm_api/mv3_utils.test.ts @@ -72,7 +72,6 @@ describe("GM XHR request linker", () => { }); it("links Firefox requestId to markerID via webRequest.onBeforeRequest", async () => { - scXhrRequests.clear(); //@ts-ignore chrome.runtime.__mockGetURLToExtensionTest = true; const RID_1 = "9470"; From dd6e5288c8f7245b01f7c8c117ceff2f0883e918 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 23 May 2026 16:04:11 +0900 Subject: [PATCH 13/13] code fix --- src/pkg/utils/xhr/fetch_xhr.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pkg/utils/xhr/fetch_xhr.ts b/src/pkg/utils/xhr/fetch_xhr.ts index 8d4278fc0..af78ae0ac 100644 --- a/src/pkg/utils/xhr/fetch_xhr.ts +++ b/src/pkg/utils/xhr/fetch_xhr.ts @@ -394,7 +394,6 @@ export class FetchXHR { this.onerror?.({ type: "error" }, (err || "Unknown Error") as Error | string); } } finally { - resolve(); this.controller = null; if (this.timeoutId != null) { clearTimeout(this.timeoutId);