diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js new file mode 100644 index 000000000..04cbb9139 --- /dev/null +++ b/example/tests/gm_xhr_test.js @@ -0,0 +1,1292 @@ +// ==UserScript== +// @name GM_xmlhttpRequest Exhaustive Test Harness v2 +// @namespace tm-gmxhr-test +// @version 1.2.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 +// @grant GM_xmlhttpRequest +// @connect httpbun.com +// @connect ipv4.download.thinkbroadband.com +// @connect nonexistent-domain-abcxyz.test +// @noframes +// ==/UserScript== + +/* + WHAT THIS DOES + -------------- + - Builds an in-page test runner panel. + - Runs a battery of tests probing GM_xmlhttpRequest options, callbacks, and edge/abnormal paths. + - Uses httpbin.org endpoints for deterministic echo/response behavior. + - Prints a summary and a detailed per-test log with assertions. + + NOTE: Endpoints now point to https://httpbun.com (a faster httpbin-like service). + See https://httpbun.com for docs and exact paths. (Also supports /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /any, etc.) +*/ + +/* + WHAT IT COVERS + -------------- + ✓ method (GET/POST/PUT/DELETE/HEAD/OPTIONS) + ✓ url & redirects (finalUrl) + ✓ headers (custom headers echoed by server) + ✓ data (form-encoded, JSON, and raw/binary body) + ✓ responseType: '', 'json', 'arraybuffer', 'blob' + ✓ overrideMimeType + ✓ timeout + ontimeout + ✓ onprogress (with streaming-ish endpoint) + ✓ onload (non-2xx still onload) + ✓ onerror (DNS/blocked host) + ✓ onabort (manual abort) + ✓ anonymous (no cookies) + ✓ basic auth (user/password) + ✓ edge cases: huge headers trimmed? invalid method; invalid URL; missing @connect domain triggers onerror +*/ + +const enableTool = true; +(function () { + "use strict"; + if (!enableTool) return; + + // ---------- Small DOM helper ---------- + function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + Object.entries(props).forEach(([k, v]) => { + if (k === "style" && typeof v === "object") Object.assign(el.style, v); + else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); + else el[k] = v; + }); + for (const c of children) el.append(c && c.nodeType ? c : document.createTextNode(String(c))); + return el; + } + + // ---------- Test Panel ---------- + const panel = h( + "div", + { + id: "gmxhr-test-panel", + style: { + position: "fixed", + bottom: "12px", + right: "12px", + width: "460px", + maxHeight: "70vh", + overflow: "auto", + zIndex: 2147483647, + background: "#111", + color: "#f5f5f5", + font: "13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + borderRadius: "10px", + boxShadow: "0 12px 30px rgba(0,0,0,.4)", + border: "1px solid #333", + }, + }, + h( + "div", + { + style: { + position: "sticky", + top: 0, + background: "#181818", + padding: "10px 12px", + borderBottom: "1px solid #333", + display: "flex", + alignItems: "center", + gap: "8px", + }, + }, + h("div", { style: { fontWeight: "600" } }, "GM_xmlhttpRequest Test Harness"), + h("div", { id: "counts", style: { marginLeft: "auto", opacity: 0.8 } }, "…"), + h("button", { id: "start", style: btn() }, "Run"), + h("button", { id: "clear", style: btn() }, "Clear") + ), + // Added: live status + pending queue (minimal UI) + h( + "div", + { id: "status", style: { padding: "6px 12px", borderBottom: "1px solid #222", opacity: 0.9 } }, + "Status: idle" + ), + h( + "details", + { id: "queueWrap", open: true, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, + h("summary", {}, "Pending tests"), + h( + "div", + { + id: "queue", + style: { + fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace", + whiteSpace: "pre-wrap", + opacity: 0.8, + }, + }, + "(none)" + ) + ), + h("div", { id: "log", style: { padding: "10px 12px" } }) + ); + document.documentElement.append(panel); + + function btn() { + return { + background: "#2a6df1", + color: "white", + border: "0", + padding: "6px 10px", + borderRadius: "6px", + cursor: "pointer", + }; + } + + const $log = panel.querySelector("#log"); + const $counts = panel.querySelector("#counts"); + const $status = panel.querySelector("#status"); + const $queue = panel.querySelector("#queue"); + panel.querySelector("#clear").addEventListener("click", () => { + $log.textContent = ""; + setCounts(0, 0, 0); + setStatus("idle"); + setQueue([]); + }); + panel.querySelector("#start").addEventListener("click", runAll); + + function logLine(html, cls = "") { + const line = h("div", { style: { padding: "6px 0", borderBottom: "1px dashed #2a2a2a" } }); + line.innerHTML = html; + if (cls) line.className = cls; + $log.prepend(line); + } + + function setCounts(p, f, s) { + $counts.textContent = `✅ ${p} ❌ ${f} ⏳ ${s}`; + } + function setStatus(text) { + $status.textContent = `Status: ${text}`; + } + function setQueue(items) { + $queue.textContent = items.length ? items.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none)"; + } + + // ---------- Assertion & request helpers ---------- + const state = { pass: 0, fail: 0, skip: 0 }; + 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)}
` : ""}`, + "fail" + ); + } + function skip(msg) { + state.skip++; + setCounts(state.pass, state.fail, state.skip); + logLine(`⏭️ ${escapeHtml(msg)}`, "skip"); + } + + function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m] + ); + } + + 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 (_) { + /* ignore */ + } + }, abortAfterMs); + } + }); + } + + // Switched base host from httpbin to httpbun (faster). + // See: https://httpbun.com (endpoints: /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /any, etc.) + const HB = "https://httpbun.com"; + + // Helper: handle minor schema diffs between httpbin/httpbun for query echo + function getQueryObj(body) { + // httpbin uses "args", httpbun may use "query" (and still often provides "args" for compatibility). + return body.args || body.query || body.params || {}; + } + + const encodedBase64 = + "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4gVGhpcyBzZW50ZW5jZSBjb250YWlucyBldmVyeSBsZXR0ZXIgb2YgdGhlIEVuZ2xpc2ggYWxwaGFiZXQgYW5kIGlzIG9mdGVuIHVzZWQgZm9yIHR5cGluZyBwcmFjdGljZSwgZm9udCB0ZXN0aW5nLCBhbmQgZW5jb2RpbmcgZXhwZXJpbWVudHMuIEJhc2U2NCBlbmNvZGluZyB0cmFuc2Zvcm1zIHRoaXMgcmVhZGFibGUgdGV4dCBpbnRvIGEgc2VxdWVuY2Ugb2YgQVNDSUkgY2hhcmFjdGVycyB0aGF0IGNhbiBzYWZlbHkgYmUgdHJhbnNtaXR0ZWQgb3Igc3RvcmVkIGluIHN5c3RlbXMgdGhhdCBoYW5kbGUgdGV4dC1vbmx5IGRhdGEu"; + const decodedBase64 = + "The quick brown fox jumps over the lazy dog. This sentence contains every letter of the English alphabet and is often used for typing practice, font testing, and encoding experiments. Base64 encoding transforms this readable text into a sequence of ASCII characters that can safely be transmitted or stored in systems that handle text-only data."; + + // ---------- Tests ---------- + const basicTests = [ + { + name: "GET basic [responseType: undefined]", + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response, decodedBase64, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET basic [responseType: ""]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response, decodedBase64, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET basic [responseType: "text"]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "text", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response, decodedBase64, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + + { + name: 'GET basic [responseType: "json"]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "json", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response, undefined, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET basic [responseType: "document"]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "document", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response instanceof XMLDocument, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET basic [responseType: "stream"]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "stream", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, undefined, "responseText ok"); + assertEq(res.response instanceof ReadableStream, true, "response ok"); + assertEq(res.responseXML, undefined, "responseXML ok"); + }, + }, + { + name: 'GET basic [responseType: "arraybuffer"]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "arraybuffer", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response instanceof ArrayBuffer, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET basic [responseType: "blob"]', + async run(fetch) { + const url = `${HB}/base64/${encodedBase64}`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "blob", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, decodedBase64, "responseText ok"); + assertEq(res.response instanceof Blob, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: "GET json [responseType: undefined]", + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(`${res.response}`.includes('"code": 200'), true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: ""]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(`${res.response}`.includes('"code": 200'), true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: "text"]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "text", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(`${res.response}`.includes('"code": 200'), true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: "json"]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "json", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: "document"]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "document", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(res.response instanceof XMLDocument, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: "stream"]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "stream", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, undefined, "responseText ok"); + assertEq(res.response instanceof ReadableStream, true, "response ok"); + assertEq(res.responseXML, undefined, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: "arraybuffer"]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "arraybuffer", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(res.response instanceof ArrayBuffer, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET json [responseType: "blob"]', + async run(fetch) { + const url = `${HB}/status/200`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "blob", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(res.response instanceof Blob, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: "GET bytes [responseType: undefined]", + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response, res.responseText, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: ""]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response, res.responseText, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: "text"]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "text", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response, res.responseText, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: "json"]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "json", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response, undefined, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: "document"]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "document", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response instanceof XMLDocument, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: "stream"]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "stream", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText, undefined, "responseText ok"); + assertEq(res.response instanceof ReadableStream, true, "response ok"); + assertEq(res.responseXML, undefined, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: "arraybuffer"]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "arraybuffer", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response instanceof ArrayBuffer, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: 'GET bytes [responseType: "blob"]', + async run(fetch) { + const url = `${HB}/bytes/32`; + const { res } = await gmRequest({ + method: "GET", + url, + responseType: "blob", + fetch, + }); + assertEq(res.status, 200, "status 200"); + assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assertEq(res.response instanceof Blob, true, "response ok"); + assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + }, + }, + { + name: "GET basic + headers + finalUrl", + async run(fetch) { + const url = `${HB}/get?x=1`; + const { res } = await gmRequest({ + method: "GET", + url, + headers: { "X-Custom": "Hello", Accept: "application/json" }, + fetch, + }); + const body = JSON.parse(res.responseText); + assertEq(res.status, 200, "status 200"); + const q = getQueryObj(body); + assertEq(q.x, "1", "query args"); + const hdrs = body.headers || {}; + assertEq(hdrs["X-Custom"] || hdrs["x-custom"], "Hello", "custom header echo"); + assertEq(res.finalUrl, url, "finalUrl matches"); + }, + }, + { + name: "Redirect handling (finalUrl changes) [default]", + async run(fetch) { + const target = `${HB}/get?z=92`; + const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`; + const { res } = await gmRequest({ + method: "GET", + url, + fetch, + }); + assertEq(res.status, 200, "status after redirect is 200"); + assertEq(res.finalUrl, target, "finalUrl is redirected target"); + }, + }, + { + name: "Redirect handling (finalUrl changes) [follow]", + async run(fetch) { + const target = `${HB}/get?z=94`; + const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`; + const { res } = await gmRequest({ + method: "GET", + url, + redirect: "follow", + fetch, + }); + assertEq(res.status, 200, "status after redirect is 200"); + assertEq(res.finalUrl, target, "finalUrl is redirected target"); + }, + }, + { + name: "Redirect handling (finalUrl changes) [error]", + async run(fetch) { + const target = `${HB}/get?z=96`; + const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`; + + let res; + try { + res = await Promise.race([ + gmRequest({ + method: "GET", + url, + 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"); + } + }, + }, + { + name: "Redirect handling (finalUrl changes) [manual]", + async run(fetch) { + const target = `${HB}/get?z=98`; + const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`; + + 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"); + }, + }, + { + name: "POST form-encoded data", + async run(fetch) { + const params = new URLSearchParams({ a: "1", b: "two" }).toString(); + const { res } = await gmRequest({ + method: "POST", + url: `${HB}/post`, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + data: params, + fetch, + }); + const body = JSON.parse(res.responseText); + assertEq(res.status, 200); + assertEq((body.form || {}).a, "1", "form a"); + assertEq((body.form || {}).b, "two", "form b"); + }, + }, + { + name: "POST JSON body", + async run(fetch) { + const payload = { alpha: 123, beta: "hey" }; + const { res } = await gmRequest({ + method: "POST", + url: `${HB}/post`, + headers: { "Content-Type": "application/json" }, + data: JSON.stringify(payload), + fetch, + }); + const body = JSON.parse(res.responseText); + assertEq(res.status, 200); + assertDeepEq(body.json, payload, "JSON echo matches"); + }, + }, + { + name: "Send binary body (Uint8Array) + responseType text", + async run(fetch) { + const bytes = new Uint8Array([1, 2, 3, 4, 5]); + const { res } = await gmRequest({ + method: "POST", + url: `${HB}/post`, + binary: true, + data: bytes, + fetch, + }); + const body = JSON.parse(res.responseText); + assertEq(res.status, 200); + assert(body.data && body.data.length > 0, "server received some data"); + }, + }, + { + name: "responseType=arraybuffer (download bytes)", + async run(fetch) { + let progressCounter = 0; + const size = 40; // MAX 90 + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/bytes/${size}`, + responseType: "arraybuffer", + onprogress() { + progressCounter++; + }, + fetch, + }); + assertEq(res.status, 200); + assert(res.response instanceof ArrayBuffer, "arraybuffer present"); + assertEq(res.response.byteLength, size, "byte length matches"); + assert(progressCounter >= 1, "progressCounter >= 1"); + }, + }, + { + name: "responseType=blob", + async run(fetch) { + let progressCounter = 0; + const size = 40; // MAX 90 + // httpbun doesn't have /image/png; use /bytes to ensure blob download + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/bytes/${size}`, + responseType: "blob", + onprogress() { + progressCounter++; + }, + fetch, + }); + assertEq(res.status, 200); + assert(res.response instanceof Blob, "blob present"); + const buf = await res.response.arrayBuffer(); + assertEq(buf.byteLength, size, "byte length matches"); + assert(progressCounter >= 1, "progressCounter >= 1"); + // Do not assert image MIME; httpbun returns octet-stream here. + }, + }, + { + name: "responseType=json", + async run(fetch) { + // Use /ip which returns JSON + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/ip`, + responseType: "json", + fetch, + }); + assertEq(res.status, 200); + assert(res.response && typeof res.response === "object", "parsed JSON object"); + assert(res.response.origin, "has JSON fields"); + }, + }, + { + name: "overrideMimeType (force text)", + async run(fetch) { + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/ip`, + overrideMimeType: "text/plain;charset=utf-8", + fetch, + }); + assertEq(res.status, 200); + assert(typeof res.responseText === "string" && res.responseText.length > 0, "responseText available"); + }, + }, + { + name: "Timeout + ontimeout", + async run(fetch) { + try { + await gmRequest({ + method: "GET", + url: `${HB}/delay/3`, // waits ~3s + timeout: 1000, + fetch, + }); + throw new Error("Expected timeout, got load"); + } catch (e) { + assertEq(e.kind, "timeout", "timeout path taken"); + } + }, + }, + { + name: "onprogress fires while downloading [arraybuffer]", + async run(fetch) { + let progressEvents = 0; + let lastLoaded = 0; + let response = null; + // Use drip endpoint to stream bytes + const { res } = await new Promise((resolve, reject) => { + const start = performance.now(); + GM_xmlhttpRequest({ + method: "GET", + url: `${HB}/drip?duration=2&delay=1&numbytes=1024`, // ~1KB + responseType: "arraybuffer", + onprogress: (ev) => { + progressEvents++; + if (ev.loaded != null) lastLoaded = ev.loaded; + setStatus(`downloading: ${lastLoaded | 0} bytes…`); + response = ev.response; + }, + onload: (res) => resolve({ res, ms: performance.now() - start }), + onerror: (res) => reject({ kind: "error", res }), + ontimeout: (res) => reject({ kind: "timeout", res }), + fetch, + }); + }); + assertEq(res.status, 200); + assert(progressEvents >= 4, "received at least 4 progress events"); + assert(lastLoaded >= 0, "progress loaded captured"); + assert(!response, "no response"); + }, + }, + { + name: "onprogress fires while downloading [stream]", + async run(fetch) { + let progressEvents = 0; + let lastLoaded = 0; + let response = null; + // Use drip endpoint to stream bytes + const { res } = await new Promise((resolve, reject) => { + const start = performance.now(); + GM_xmlhttpRequest({ + method: "GET", + url: `${HB}/drip?duration=2&delay=1&numbytes=1024`, // ~1KB + responseType: "stream", + onloadstart: async (ev) => { + const reader = ev.response?.getReader(); + if (reader) { + let loaded = 0; + while (true) { + const { done, value } = await reader.read(); // value is Uint8Array + if (value) { + progressEvents++; + loaded += value.length; + if (loaded != null) lastLoaded = loaded; + setStatus(`downloading: ${loaded | 0} bytes…`); + response = ev.response; + } + if (done) break; + } + } + }, + onloadend: (res) => resolve({ res, ms: performance.now() - start }), + onerror: (res) => reject({ kind: "error", res }), + ontimeout: (res) => reject({ kind: "timeout", res }), + fetch, + }); + }); + assertEq(res.status, 200); + assert(progressEvents >= 4, "received at least 4 progress events"); + assert(lastLoaded >= 0, "progress loaded captured"); + assert(response instanceof ReadableStream && typeof response.getReader === "function", "response"); + }, + }, + { + name: "HEAD request - ensure body exist", + async run(fetch) { + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/response-headers`, + fetch, + }); + assertEq(res.status, 200); + assert((res.responseText || "")?.length > 0, "body for HEAD"); + assert(typeof res.responseHeaders === "string", "response headers present"); + }, + }, + { + name: "HEAD request - without body", + async run(fetch) { + const { res } = await gmRequest({ + method: "HEAD", + url: `${HB}/response-headers`, + fetch, + }); + assertEq(res.status, 200); + assertEq(res.responseText || "", "", "no body for HEAD"); + assert(typeof res.responseHeaders === "string", "response headers present"); + }, + }, + { + name: "OPTIONS request", + async run(fetch) { + const { res } = await gmRequest({ + method: "OPTIONS", + url: `${HB}/any`, + fetch, + }); + // httpbun commonly returns 200 for OPTIONS + assert(res.status === 200 || res.status === 204, "200/204 on OPTIONS"); + }, + }, + { + name: "DELETE request", + async run(fetch) { + const { res } = await gmRequest({ + method: "DELETE", + url: `${HB}/delete`, + fetch, + }); + assertEq(res.status, 200); + const body = JSON.parse(res.responseText); + assertEq(body.method, "DELETE", "server saw DELETE"); + }, + }, + { + name: 'anonymous TEST - set cookie "abc"', + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/cookies/set/abc/123`, + fetch, + }); + }, + }, + { + name: "anonymous TEST - get cookie", + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/cookies`, + fetch, + }); + assertEq(res.status, 200); + const body = JSON.parse(res.responseText); + const cookieABC = body.cookies.abc; + assertEq(cookieABC, "123", "cookie abc=123"); + }, + }, + { + name: "anonymous: true (no cookies sent)", + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/headers`, + anonymous: true, + fetch, + }); + const body = JSON.parse(res.responseText); + const cookies = body.headers.Cookie || body.headers.cookie; + assert(!`${cookies}`.includes("abc=123"), "no Cookie header when anonymous"); + }, + }, + { + name: "anonymous: false (cookies sent)", + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/headers`, + fetch, + }); + const body = JSON.parse(res.responseText); + const cookies = body.headers.Cookie || body.headers.cookie; + assert(`${cookies}`.includes("abc=123"), "Cookie header"); + }, + }, + { + name: "anonymous TEST - delete cookies", + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/cookies/delete`, + anonymous: true, + fetch, + }); + }, + }, + { + name: 'anonymous: true[2] - set cookie "def"', + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/cookies/set/def/456`, + anonymous: true, + fetch, + }); + }, + }, + { + name: "anonymous: true[2] (no cookies sent)", + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/headers`, + anonymous: true, + fetch, + }); + const body = JSON.parse(res.responseText); + const cookies = body.headers.Cookie || body.headers.cookie; + assert(!cookies, "no Cookie header when anonymous"); + }, + }, + { + name: "anonymous TEST - delete cookies", + async run(fetch) { + // httpbin echoes Cookie header in headers + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/cookies/delete`, + anonymous: true, + fetch, + }); + }, + }, + { + name: "Basic auth with user/password", + async run(fetch) { + const user = "user", + pass = "passwd"; + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/basic-auth/${user}/${pass}`, + user, + password: pass, + fetch, + }); + assertEq(res.status, 200); + const body = JSON.parse(res.responseText); + assertEq(body.authenticated, true, "authenticated true"); + assertEq(body.user, "user", "user echoed"); + }, + }, + { + name: "Non-2xx stays in onload (status 418)", + async run(fetch) { + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/status/418`, + fetch, + }); + assertEq(res.status, 418, "418 I'm a teapot"); + // Still triggers onload, not onerror + }, + }, + { + name: "Invalid method -> expected server 405 or 200 echo", + async run(fetch) { + // httpbun accepts any method on /headers (per docs), so status may be 200 + const { res } = await gmRequest({ + method: "FOOBAR", + url: `${HB}/headers`, + fetch, + }); + assert([200, 405].includes(res.status), "200 or 405 depending on server handling"); + }, + }, + { + name: "onerror for blocked domain (missing @connect) [https]", + async run(fetch) { + // We did not include @connect for example.org; Tampermonkey should block and call onerror. + try { + await gmRequest({ + method: "GET", + url: "https://example.org/", + fetch, + }); + throw new Error("Expected onerror due to @connect, but got onload"); + } catch (e) { + assertEq(e.kind, "error", "onerror path taken"); + assert(e.res, "e.res exists"); + assertEq(e.res.status, 0, "status 0"); + assertEq(e.res.statusText, "", 'statusText ""'); + assertEq(e.res.finalUrl, undefined, "finalUrl undefined"); + assertEq(e.res.readyState, 4, "readyState DONE"); + assertEq(!e.res.response, true, "!response ok"); + assertEq(e.res.responseText, "", 'responseText ""'); + assertEq(e.res.responseXML, undefined, "responseXML undefined"); + assertEq(typeof (e.res.error || undefined), "string", "error set"); + assertEq( + `${e.res.error}`.includes(`Refused to connect to "https://example.org/": `), + true, + "Refused to connect to ..." + ); + } + }, + }, + { + name: "onerror for blocked domain (missing @connect) [http]", + async run(fetch) { + try { + await gmRequest({ + method: "GET", + url: "http://domain-abcxyz.test/", + fetch, + }); + throw new Error("Expected error, got load"); + } catch (e) { + assertEq(e.kind, "error", "onerror path taken"); + assert(e.res, "e.res exists"); + assertEq(e.res.status, 0, "status 0"); + assertEq(e.res.statusText, "", 'statusText ""'); + assertEq(e.res.finalUrl, undefined, "finalUrl undefined"); + assertEq(e.res.readyState, 4, "readyState DONE"); + assertEq(!e.res.response, true, "!response ok"); + assertEq(e.res.responseText, "", 'responseText ""'); + assertEq(e.res.responseXML, undefined, "responseXML undefined"); + assertEq(typeof (e.res.error || undefined), "string", "error set"); + assertEq( + `${e.res.error}`.includes(`Refused to connect to "http://domain-abcxyz.test/": `), + true, + "Refused to connect to ..." + ); + } + }, + }, + { + name: "onerror for DNS failure", + async run(fetch) { + try { + await gmRequest({ + method: "GET", + url: "https://nonexistent-domain-abcxyz.test/", + fetch, + }); + throw new Error("Expected error, got load"); + } catch (e) { + assertEq(e.kind, "error", "onerror path taken"); + assert(e.res, "e.res exists"); + assertEq(!e.res.response, true, "!response ok"); + assertEq(e.res.responseXML, undefined, "responseXML undefined"); + assertEq(e.res.responseHeaders, "", 'responseHeaders ""'); + assertEq(e.res.readyState, 4, "readyState 4"); + } + }, + }, + { + name: "Manual abort + onabort", + async run(fetch) { + try { + await Promise.race([ + gmRequest( + { + method: "GET", + url: `${HB}/delay/5`, + fetch, + }, + { abortAfterMs: 200 } + ), + new Promise((resolve) => setTimeout(resolve, 800)), + ]); + throw new Error("Expected abort, got load"); + } catch (e) { + assertEq(e.kind, "abort", "abort path taken"); + } + }, + }, + ]; + + const tests = [ + ...basicTests, + ...basicTests.map((item) => { + return { ...item, useFetch: true }; + }), + ]; + + // ---------- Assertion utils ---------- + function assert(condition, msg) { + if (!condition) throw new Error(msg || "assertion failed"); + } + function assertEq(a, b, msg) { + if (a !== b) throw new Error(msg ? `${msg}: expected ${b}, got ${a}` : `expected ${b}, got ${a}`); + } + function assertDeepEq(a, b, msg) { + const aj = JSON.stringify(a); + const bj = JSON.stringify(b); + if (aj !== bj) throw new Error(msg ? `${msg}: expected ${bj}, got ${aj}` : `deep equal failed`); + } + function getHeader(headersStr, key) { + const lines = (headersStr || "").split(/\r?\n/); + const line = lines.find((l) => l.toLowerCase().startsWith(key.toLowerCase() + ":")); + return line ? line.split(":").slice(1).join(":").trim() : ""; + } + + // ---------- Runner ---------- + async function runAll() { + // reset counts + state.pass = state.fail = state.skip = 0; + setCounts(0, 0, 0); + const names = tests.map((t) => t.name); + setQueue(names.slice()); + logLine(`Starting GM_xmlhttpRequest test suite — ${new Date().toLocaleString()}`); + + for (let i = 0; i < tests.length; i++) { + const t = tests[i]; + const title = `• ${t.name}`; + const t0 = performance.now(); + setStatus(`running (${i + 1}/${tests.length}): ${t.name}`); + try { + logLine(`▶️ ${escapeHtml(t.name)} (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); + } finally { + // update pending list + setQueue(names.slice(i + 1)); + } + } + + setStatus("done"); + logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`); + } + + function fmtMs(ms) { + return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; + } + + // Auto-run once after a short delay to let the page settle + setTimeout(() => { + // Only auto-run if not already run in this page session + if (!window.__gmxhr_test_autorun__) { + window.__gmxhr_test_autorun__ = true; + runAll(); + } + }, 600); +})(); diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index ccd1ef7bf..2df2e3f92 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -1,37 +1,8 @@ +import EventEmitter from "eventemitter3"; + export default class WebRequest { sendHeader?: (details: chrome.webRequest.OnSendHeadersDetails) => chrome.webRequest.BlockingResponse | void; - mockXhr(xhr: any): any { - return () => { - const ret = new xhr(); - const header: chrome.webRequest.HttpHeader[] = []; - ret.setRequestHeader = (k: string, v: string) => { - header.push({ - name: k, - value: v, - }); - }; - const oldSend = ret.send.bind(ret); - ret.send = (data: any) => { - header.push({ - name: "cookie", - value: "website=example.com", - }); - const resp = this.sendHeader?.({ - method: ret.method, - url: ret.url, - requestHeaders: header, - initiator: chrome.runtime.getURL(""), - } as chrome.webRequest.OnSendHeadersDetails) as chrome.webRequest.BlockingResponse; - resp.requestHeaders?.forEach((h) => { - ret._authorRequestHeaders!.addHeader(h.name, h.value); - }); - oldSend(data); - }; - return ret; - }; - } - onBeforeSendHeaders = { addListener: (callback: any) => { this.sendHeader = callback; @@ -49,4 +20,27 @@ export default class WebRequest { // TODO }, }; + + onBeforeRequest = { + counter: 0, + EE: new EventEmitter(), + addListener: function (callback: (...args: any[]) => any) { + this.EE.addListener("onBeforeRequest", (params) => { + callback(params); + }); + // TODO + }, + }; + + onBeforeRedirect = { + addListener: () => { + // TODO + }, + }; + + onErrorOccurred = { + addListener: () => { + // TODO + }, + }; } diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index a4c96bec9..65578c146 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -2,11 +2,11 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import { v4 as uuidv4 } from "uuid"; import type { Message } from "@Packages/message/types"; import EventEmitter from "eventemitter3"; -import { GMContextApiGet } from "./gm_context"; -import { createGMBase } from "./gm_api"; -import { protect } from "./gm_context"; +import { GMContextApiGet } from "./gm_api/gm_context"; +import { protect } from "./gm_api/gm_context"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; +import { createGMBase } from "./gm_api/gm_api"; // 构建沙盒上下文 export const createContext = ( diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6b82ed296..7187f35f9 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -6,8 +6,8 @@ import { compileScript } from "./utils"; import type { Message } from "@Packages/message/types"; import type { ScriptLoadInfo } from "../service_worker/types"; import type { ValueUpdateDataEncoded } from "./types"; -import { evaluateGMInfo } from "./gm_info"; -import { type IGM_Base } from "./gm_api"; +import { evaluateGMInfo } from "./gm_api/gm_info"; +import type { IGM_Base } from "./gm_api/gm_api"; // 执行脚本,控制脚本执行与停止 export default class ExecScript { diff --git a/src/app/service/content/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts similarity index 99% rename from src/app/service/content/gm_api.test.ts rename to src/app/service/content/gm_api/gm_api.test.ts index 05e8b8f24..64c1a26f4 100644 --- a/src/app/service/content/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import ExecScript from "./exec_script"; -import type { ScriptLoadInfo } from "../service_worker/types"; -import type { GMInfoEnv, ScriptFunc } from "./types"; -import { compileScript, compileScriptCode } from "./utils"; +import ExecScript from "../exec_script"; +import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; +import type { GMInfoEnv, ScriptFunc } from "../types"; +import { compileScript, compileScriptCode } from "../utils"; import type { Message } from "@Packages/message/types"; import { encodeMessage } from "@App/pkg/utils/message_value"; import { v4 as uuidv4 } from "uuid"; diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts similarity index 80% rename from src/app/service/content/gm_api.ts rename to src/app/service/content/gm_api/gm_api.ts index 3eaf219f9..9f8328d7c 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -8,19 +8,21 @@ import type { SWScriptMenuItemOption, TScriptMenuItemID, TScriptMenuItemKey, -} from "../service_worker/types"; + MessageRequest, +} from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; import { type ScriptRunResource } from "@App/app/repo/scripts"; -import type { ValueUpdateDataEncoded } from "./types"; -import type { MessageRequest } from "../service_worker/types"; +import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { getStorageName } from "@App/pkg/utils/utils"; -import { ListenerManager } from "./listener_manager"; +import { ListenerManager } from "../listener_manager"; import { decodeMessage, encodeMessage } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; +import type { ContextType } from "./gm_xhr"; +import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; // 内部函数呼叫定义 export interface IGM_Base { @@ -30,6 +32,11 @@ export interface IGM_Base { emitEvent(event: string, eventId: string, data: any): void; } +export interface GMRequestHandle { + /** Abort the ongoing request */ + abort: () => void; +} + const integrity = {}; // 仅防止非法实例化 let valChangeCounterId = 0; @@ -457,7 +464,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public CAT_createBlobUrl(blob: Blob): Promise { - return this.sendMessage("CAT_createBlobUrl", [blob]); + return Promise.resolve(toBlobURL(this, blob)); } // 辅助GM_xml获取blob数据 @@ -468,8 +475,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public async CAT_fetchDocument(url: string): Promise { - const nodeId = await this.sendMessage("CAT_fetchDocument", [url]); - return (this.message).getAndDelRelatedTarget(nodeId) as Document; + return urlToDocumentInContentPage(this, url); } static _GM_cookie( @@ -574,7 +580,7 @@ export default class GMApi extends GM_Base { // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 menuIdCounter: number | undefined; - // 菜单注冊累计器 - 用於穩定同一Tab不同frame之選項的單獨項目不合併狀態 + // 菜单注册累计器 - 用于稳定同一Tab不同frame之选项的单独项目不合并状态 // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 regMenuCounter: number | undefined; @@ -757,7 +763,7 @@ export default class GMApi extends GM_Base { file: details.file, }; if (action === "upload") { - const url = await this.CAT_createBlobUrl(details.data); + const url = await toBlobURL(this, details.data); sendDetails.data = url; } this.sendMessage("CAT_fileStorage", [action, sendDetails]).then(async (resp: { action: string; data: any }) => { @@ -783,308 +789,244 @@ export default class GMApi extends GM_Base { }); } - static _GM_xmlhttpRequest(a: GMApi, details: GMTypes.XHRDetails) { + // 用于脚本跨域请求,需要@connect domain指定允许的域名 + @GMContext.API({ + depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"], + }) + public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { + const { abort } = GM_xmlhttpRequest(this, details, false); + return { abort }; + } + + @GMContext.API({ depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"] }) + public ["GM.xmlHttpRequest"](details: GMTypes.XHRDetails): Promise & GMRequestHandle { + const { retPromise, abort } = GM_xmlhttpRequest(this, details, true); + const ret = retPromise as Promise & GMRequestHandle; + ret.abort = abort; + return ret; + } + + /** + * + * SC的 downloadMode 设置在API呼叫,TM 的 downloadMode 设置在扩展设定 + * native, disabled, browser + * native: 后台xhr下载 -> 后台chrome.download API,disabled: 禁止下载,browser: 后台chrome.download API + * + */ + @GMContext.API({ alias: "GM.download" }) + static _GM_download(a: GMApi, details: GMTypes.DownloadDetails, requirePromise: boolean) { if (a.isInvalidContext()) { return { + retPromise: requirePromise ? Promise.reject("GM_download: Invalid Context") : null, abort: () => {}, }; } - const u = new URL(details.url, window.location.href); - const headers = details.headers; - if (headers) { - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === "cookie") { - details.cookie = headers[key]; - delete headers[key]; - } - } - } - - const param: GMSend.XHRDetails = { - method: details.method, - timeout: details.timeout, - url: u.href, - headers: details.headers, - cookie: details.cookie, - context: details.context, - responseType: details.responseType, - overrideMimeType: details.overrideMimeType, - anonymous: details.anonymous, - user: details.user, - password: details.password, - redirect: details.redirect, - fetch: details.fetch, - }; - if (!param.headers) { - param.headers = {}; - } - if (details.nocache) { - param.headers["Cache-Control"] = "no-cache"; - } + let retPromiseResolve: (value: unknown) => void | undefined; + let retPromiseReject: (reason?: any) => void | undefined; + const retPromise = requirePromise + ? new Promise((resolve, reject) => { + retPromiseResolve = resolve; + retPromiseReject = reject; + }) + : null; + const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; + let aborted = false; let connect: MessageConnect; - const handler = async () => { - // 处理数据 - if (details.data instanceof FormData) { - // 处理FormData - param.dataType = "FormData"; - const keys: { [key: string]: boolean } = {}; - details.data.forEach((val, key) => { - keys[key] = true; - }); - // 处理FormData中的数据 - const data = (await Promise.all( - Object.keys(keys).flatMap((key) => - (details.data).getAll(key).map((val) => - val instanceof File - ? a.CAT_createBlobUrl(val).then( - (url) => - ({ - key, - type: "file", - val: url, - filename: val.name, - }) as GMSend.XHRFormData - ) - : ({ - key, - type: "text", - val, - } as GMSend.XHRFormData) - ) - ) - )) as GMSend.XHRFormData[]; - param.data = data; - } else if (details.data instanceof Blob) { - // 处理blob - param.dataType = "Blob"; - param.data = await a.CAT_createBlobUrl(details.data); - } else { - param.data = details.data; + let nativeAbort: (() => any) | null = null; + const contentContext = details.context; + const makeCallbackParam = , K extends T & { data?: any; context?: ContextType }>( + o: T + ): K => { + const retParam = { ...o } as unknown as K; + if (o?.data) { + retParam.data = o.data; } - - // 处理返回数据 - let readerStream: ReadableStream | undefined; - let controller: ReadableStreamDefaultController | undefined; - // 如果返回类型是arraybuffer或者blob的情况下,需要将返回的数据转化为blob - // 在background通过URL.createObjectURL转化为url,然后在content页读取url获取blob对象 - const responseType = details.responseType?.toLocaleLowerCase(); - const warpResponse = (old: (xhr: GMTypes.XHRResponse) => void) => { - if (responseType === "stream") { - readerStream = new ReadableStream({ - start(ctrl) { - controller = ctrl; - }, - }); - } - return async (xhr: GMTypes.XHRResponse) => { - if (xhr.response) { - if (responseType === "document") { - xhr.response = await a.CAT_fetchDocument(xhr.response); - xhr.responseXML = xhr.response; - xhr.responseType = "document"; - } else { - const resp = await a.CAT_fetchBlob(xhr.response); - if (responseType === "arraybuffer") { - xhr.response = await resp.arrayBuffer(); - } else { - xhr.response = resp; - } - } - } - if (responseType === "stream") { - xhr.response = readerStream; - } - old(xhr); - }; - }; - if ( - responseType === "arraybuffer" || - responseType === "blob" || - responseType === "document" || - responseType === "stream" - ) { - if (details.onload) { - details.onload = warpResponse(details.onload); - } - if (details.onreadystatechange) { - details.onreadystatechange = warpResponse(details.onreadystatechange); - } - if (details.onloadend) { - details.onloadend = warpResponse(details.onloadend); - } - // document类型读取blob,然后在content页转化为document对象 - if (responseType === "document") { - param.responseType = "blob"; - } - if (responseType === "stream") { - if (details.onloadstart) { - details.onloadstart = warpResponse(details.onloadstart); + if (typeof contentContext !== "undefined") { + retParam.context = contentContext; + } + return retParam as K; + }; + const handle = async () => { + const url = await urlPromiseLike; + const downloadMode = details.downloadMode || "native"; // native = sc_default; browser = chrome api + details.url = url; + if (downloadMode === "browser" || url.startsWith("blob:")) { + if (typeof details.user === "string" && details.user) { + // scheme://[user[:password]@]host[:port]/path[?query][#fragment] + try { + const u = new URL(details.url); + const userPart = `${encodeURIComponent(details.user)}`; + const passwordPart = details.password ? `:${encodeURIComponent(details.password)}` : ""; + details.url = `${u.protocol}//${userPart}${passwordPart}@${u.host}${u.pathname}${u.search}${u.hash}`; + } catch { + // ignored } } - } - - // 发送信息 - a.connect("GM_xmlhttpRequest", [param]).then((con) => { + const con = await a.connect("GM_download", [ + { + method: details.method, + downloadMode: "browser", // 默认使用xhr下载 + url: url as string, + name: details.name, + headers: details.headers, + saveAs: details.saveAs, + timeout: details.timeout, + cookie: details.cookie, + anonymous: details.anonymous, + } as GMTypes.DownloadDetails, + ]); + if (aborted) return; connect = con; - con.onMessage((data) => { - if (data.code === -1) { - // 处理错误 - LoggerCore.logger().error("GM_xmlhttpRequest error", { - code: data.code, - message: data.message, - }); - if (details.onerror) { - details.onerror({ - readyState: 4, - error: data.message || "unknown", - }); - } - return; - } - // 处理返回 + connect.onMessage((data) => { switch (data.action) { case "onload": - details.onload?.(data.data); - break; - case "onloadend": - details.onloadend?.(data.data); - break; - case "onloadstart": - details.onloadstart?.(data.data); + details.onload?.(makeCallbackParam({ ...data.data })); + retPromiseResolve?.(data.data); break; case "onprogress": - details.onprogress?.(data.data); - break; - case "onreadystatechange": - details.onreadystatechange && details.onreadystatechange(data.data); + details.onprogress?.(makeCallbackParam({ ...data.data })); + retPromiseReject?.(new Error("Timeout ERROR")); break; case "ontimeout": - details.ontimeout?.(); + details.ontimeout?.(makeCallbackParam({})); + retPromiseReject?.(new Error("Timeout ERROR")); break; case "onerror": - details.onerror?.(data.data); - break; - case "onabort": - details.onabort?.(); - break; - case "onstream": - controller?.enqueue(new Uint8Array(data.data)); + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + retPromiseReject?.(new Error("Unknown ERROR")); break; default: - LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", { + LoggerCore.logger().warn("GM_download resp is error", { data, }); + retPromiseReject?.(new Error("Unexpected Internal ERROR")); break; } }); - }); + } else { + // native + const xhrParams = { + url: url, + fetch: true, // 跟随TM使用 fetch; 使用 fetch 避免 1) 大量数据存放offscreen xhr 2) vivaldi offscreen client block + responseType: "blob", + onloadend: async (res) => { + if (aborted) return; + if (res.response instanceof Blob) { + const url = URL.createObjectURL(res.response); // 生命周期跟随当前 content/page 而非 offscreen + const con = await a.connect("GM_download", [ + { + method: details.method, + downloadMode: "browser", + url: url as string, + name: details.name, + headers: details.headers, + saveAs: details.saveAs, + timeout: details.timeout, + cookie: details.cookie, + anonymous: details.anonymous, + } as GMTypes.DownloadDetails, + ]); + if (aborted) return; + connect = con; + connect.onMessage((data) => { + switch (data.action) { + case "onload": + details.onload?.(makeCallbackParam({ ...data.data })); + retPromiseResolve?.(data.data); + setTimeout(() => { + // 释放不需要的 URL + URL.revokeObjectURL(url); + }, 1); + break; + case "ontimeout": + details.ontimeout?.(makeCallbackParam({})); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "onerror": + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + retPromiseReject?.(new Error("Unknown ERROR")); + break; + default: + LoggerCore.logger().warn("GM_download resp is error", { + data, + }); + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + break; + } + }); + } + }, + onload: () => { + // details.onload?.(makeCallbackParam({})) + }, + onprogress: (e) => { + details.onprogress?.(makeCallbackParam({ ...e })); + }, + ontimeout: () => { + details.ontimeout?.(makeCallbackParam({})); + }, + onerror: () => { + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + }, + } as GMTypes.XHRDetails; + if (typeof details.headers === "object") { + xhrParams.headers = details.headers; + } + // -- 其他参数 -- + if (typeof details.method === "string") { + xhrParams.method = details.method || "GET"; + } + if (typeof details.timeout === "number") { + xhrParams.timeout = details.timeout; + } + if (typeof details.cookie === "string") { + xhrParams.cookie = details.cookie; + } + if (typeof details.anonymous === "boolean") { + xhrParams.anonymous = details.anonymous; + } + if (typeof details.user === "string" && details.user) { + xhrParams.user = details.user; + xhrParams.password = details.password || ""; + } + // -- 其他参数 -- + const { retPromise, abort } = GM_xmlhttpRequest(a, xhrParams, true, true); + retPromise?.catch(() => { + if (aborted) return; + retPromiseReject?.(new Error("Native Download ERROR")); + }); + nativeAbort = abort; + } }; - // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 - handler(); + handle().catch(console.error); + return { + retPromise, abort: () => { - if (connect) { - connect.disconnect(); - } + aborted = true; + connect?.disconnect(); + nativeAbort?.(); }, }; } // 用于脚本跨域请求,需要@connect domain指定允许的域名 - @GMContext.API({ - depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"], - }) - public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - return _GM_xmlhttpRequest(this, details); + @GMContext.API() + public GM_download(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; + const { abort } = _GM_download(this, details as GMTypes.DownloadDetails, false); + return { abort }; } - @GMContext.API({ depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"] }) - public ["GM.xmlHttpRequest"](details: GMTypes.XHRDetails): Promise { - let abort: { abort: () => void }; - const ret = new Promise((resolve, reject) => { - const oldOnload = details.onload; - details.onloadend = (xhr: GMTypes.XHRResponse) => { - oldOnload && oldOnload(xhr); - resolve(xhr); - }; - const oldOnerror = details.onerror; - details.onerror = (error: any) => { - oldOnerror && oldOnerror(error); - reject(error); - }; - abort = _GM_xmlhttpRequest(this, details); - }); - //@ts-ignore - ret.abort = () => { - abort && abort.abort && abort.abort(); - }; + @GMContext.API() + public ["GM.download"](arg1: GMTypes.DownloadDetails | string, arg2?: string) { + const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; + const { retPromise, abort } = _GM_download(this, details as GMTypes.DownloadDetails, true); + const ret = retPromise as Promise & GMRequestHandle; + ret.abort = abort; return ret; } - @GMContext.API({ alias: "GM.download" }) - GM_download(url: GMTypes.DownloadDetails | string, filename?: string): GMTypes.AbortHandle { - if (this.isInvalidContext()) { - return { - abort: () => {}, - }; - } - let details: GMTypes.DownloadDetails; - if (typeof url === "string") { - details = { - name: filename || "", - url, - }; - } else { - details = url; - } - let connect: MessageConnect; - this.connect("GM_download", [ - { - method: details.method, - downloadMode: details.downloadMode || "native", // 默认使用xhr下载 - url: details.url, - name: details.name, - headers: details.headers, - saveAs: details.saveAs, - timeout: details.timeout, - cookie: details.cookie, - anonymous: details.anonymous, - } as GMTypes.DownloadDetails, - ]).then((con) => { - connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - details.onload && details.onload(data.data); - break; - case "onprogress": - details.onprogress && details.onprogress(data.data); - break; - case "ontimeout": - details.ontimeout && details.ontimeout(); - break; - case "onerror": - details.onerror && - details.onerror({ - error: "unknown", - }); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - break; - } - }); - }); - - return { - abort: () => { - connect?.disconnect(); - }, - }; - } - @GMContext.API({ depend: ["GM_closeNotification", "GM_updateNotification"], alias: "GM.notification", @@ -1420,4 +1362,4 @@ export default class GMApi extends GM_Base { export const { createGMBase } = GM_Base; // 从 GMApi 对象中解构出内部函数,用于后续本地使用,不导出 -const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_xmlhttpRequest } = GMApi; +const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_download } = GMApi; diff --git a/src/app/service/content/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts similarity index 96% rename from src/app/service/content/gm_context.ts rename to src/app/service/content/gm_api/gm_context.ts index 13564a578..25bfaa109 100644 --- a/src/app/service/content/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,4 +1,4 @@ -import type { ApiParam, ApiValue } from "./types"; +import type { ApiParam, ApiValue } from "../types"; const apis: Map = new Map(); diff --git a/src/app/service/content/gm_info.ts b/src/app/service/content/gm_api/gm_info.ts similarity index 92% rename from src/app/service/content/gm_info.ts rename to src/app/service/content/gm_api/gm_info.ts index bd698a22e..37b39c535 100644 --- a/src/app/service/content/gm_info.ts +++ b/src/app/service/content/gm_api/gm_info.ts @@ -1,6 +1,6 @@ import { ExtVersion } from "@App/app/const"; -import type { GMInfoEnv } from "./types"; -import type { ScriptLoadInfo } from "../service_worker/types"; +import type { GMInfoEnv } from "../types"; +import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; // 获取脚本信息和管理器信息 export function evaluateGMInfo(envInfo: GMInfoEnv, script: ScriptLoadInfo) { diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts new file mode 100644 index 000000000..0b030f33d --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -0,0 +1,627 @@ +import type { CustomEventMessage } from "@Packages/message/custom_event_message"; +import type GMApi from "./gm_api"; +import { dataEncode } from "@App/pkg/utils/xhr/xhr_data"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; +import { base64ToUint8, concatUint8 } from "@App/pkg/utils/datatype"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import LoggerCore from "@App/app/logger/core"; + +export type ContextType = unknown; + +export type GMXHRResponseType = { + DONE: number; + HEADERS_RECEIVED: number; + LOADING: number; + OPENED: number; + UNSENT: number; + RESPONSE_TYPE_TEXT: string; + RESPONSE_TYPE_ARRAYBUFFER: string; + RESPONSE_TYPE_BLOB: string; + RESPONSE_TYPE_DOCUMENT: string; + RESPONSE_TYPE_JSON: string; + RESPONSE_TYPE_STREAM: string; + context?: ContextType; + finalUrl: string; + readyState: 0 | 1 | 4 | 2 | 3; + status: number; + statusText: string; + responseHeaders: string; + responseType: "" | "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; + readonly response: string | ArrayBuffer | Blob | Document | ReadableStream> | null; + readonly responseXML: Document | null; + readonly responseText: string; + toString: () => string; + error?: string; +}; + +export type GMXHRResponseTypeWithError = GMXHRResponseType & Required>; + +export const toBlobURL = (a: GMApi, blob: Blob): Promise | string => { + // content_GMAPI 都应该在前台的内容脚本或真实页面执行。如果没有 typeof URL.createObjectURL 才使用信息传递交给后台 + if (typeof URL.createObjectURL === "function") { + return URL.createObjectURL(blob); + } else { + return a.sendMessage("CAT_createBlobUrl", [blob]); + } +}; + +/** Convert a Blob/File to base64 data URL */ +export const blobToDataURL = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.onabort = reject; + reader.readAsDataURL(blob); + }); +}; + +export const convObjectToURL = async (object: string | URL | Blob | File | undefined | null) => { + let url = ""; + if (typeof object === "string") { + url = object; + } else if (object instanceof URL) { + url = object.href; + } else if (object instanceof Blob) { + // 不使用 blob URL + // 1. service worker 不能生成 blob URL + // 2. blob URL 有效期管理麻烦 + + const blob = object; + url = await blobToDataURL(blob); + } + return url; +}; + +export const urlToDocumentInContentPage = async (a: GMApi, url: string) => { + // url (e.g. blob url) -> XMLHttpRequest (CONTENT) -> Document (CONTENT) + const nodeId = await a.sendMessage("CAT_fetchDocument", [url]); + return (a.message).getAndDelRelatedTarget(nodeId) as Document; +}; + +export function GM_xmlhttpRequest( + a: GMApi, + details: GMTypes.XHRDetails, + requirePromise: boolean, + isDownload: boolean = false +) { + let reqDone = false; + if (a.isInvalidContext()) { + return { + retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, + abort: () => {}, + }; + } + let retPromiseResolve: (value: unknown) => void | undefined; + let retPromiseReject: (reason?: any) => void | undefined; + const retPromise = requirePromise + ? new Promise((resolve, reject) => { + retPromiseResolve = resolve; + retPromiseReject = reject; + }) + : null; + const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; + const dataPromise = dataEncode(details.data); + const headers = details.headers; + if (headers) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "cookie") { + details.cookie = headers[key]; + delete headers[key]; + } + } + } + const contentContext = details.context; + + const param: GMSend.XHRDetails = { + method: details.method, + timeout: details.timeout, + url: "", + headers: details.headers, + cookie: details.cookie, + responseType: details.responseType, + overrideMimeType: details.overrideMimeType, + anonymous: details.anonymous, + user: details.user, + password: details.password, + redirect: details.redirect, + fetch: details.fetch, + }; + if (!param.headers) { + param.headers = {}; + } + if (details.nocache) { + param.headers["Cache-Control"] = "no-cache"; + } + let connect: MessageConnect | null; + const responseTypeOriginal = details.responseType?.toLocaleLowerCase() || ""; + let doAbort: any = null; + const handler = async () => { + const [urlResolved, dataResolved] = await Promise.all([urlPromiseLike, dataPromise]); + const u = new URL(urlResolved, window.location.href); + param.url = u.href; + param.data = dataResolved; + + // 处理返回数据 + let readerStream: ReadableStream | undefined; + let controller: ReadableStreamDefaultController | undefined; + // 如果返回类型是arraybuffer或者blob的情况下,需要将返回的数据转化为blob + // 在background通过URL.createObjectURL转化为url,然后在content页读取url获取blob对象 + if (responseTypeOriginal === "stream") { + readerStream = new ReadableStream({ + start(ctrl) { + controller = ctrl; + }, + }); + } else { + // document类型读取blob,然后在content页转化为document对象 + switch (responseTypeOriginal) { + case "arraybuffer": + case "blob": + param.responseType = "arraybuffer"; + break; + case "document": + case "json": + case "": + case "text": + default: + param.responseType = "text"; + break; + } + } + const xhrType = param.responseType; + const responseType = responseTypeOriginal; // 回传用 + + // 发送信息 + a.connect(isDownload ? "GM_download" : "GM_xmlhttpRequest", [param]).then((con) => { + // 注意。在此 callback 里,不应直接存取 param, 否则会影响 GC + connect = con; + const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC + const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC + let finalResultBuffers: Uint8Array | null = null; // 函数参考清掉后,变数会被GC + let finalResultText: string | null = null; // 函数参考清掉后,变数会被GC + let isEmptyResult = true; + const asyncTaskId = `${Date.now}:${Math.random()}`; + let lastStateAndCode = ""; + + let errorOccur: string | null = null; + let response: unknown = null; + let responseText: string | undefined | false = ""; + let responseXML: unknown = null; + let resultType = 0; + if (readerStream) { + response = readerStream; + responseText = undefined; // 兼容 + responseXML = undefined; // 兼容 + } + readerStream = undefined; + + let refCleanup: (() => void) | null = () => { + // 清掉函数参考,避免各变数参考无法GC + makeXHRCallbackParam = null; + onMessageHandler = null; + doAbort = null; + refCleanup = null; + connect = null; + }; + + const makeXHRCallbackParam_ = ( + res: { + // + finalUrl: string; + readyState: 0 | 4 | 2 | 3 | 1; + status: number; + statusText: string; + responseHeaders: string; + error?: string; + // + useFetch: boolean; + eventType: string; + ok: boolean; + contentType: string; + } & Record + ) => { + let resError: Record | null = null; + if ( + (typeof res.error === "string" && + (res.status === 0 || res.status >= 300 || res.status < 200) && + !res.statusText && + isEmptyResult) || + res.error === "aborted" + ) { + resError = { + error: res.error as string, + readyState: res.readyState as 0 | 4 | 2 | 3 | 1, + // responseType: responseType as "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | "", + response: null, + responseHeaders: res.responseHeaders as string, + responseText: "", + status: res.status as number, + statusText: "", + }; + } + let retParam; + if (resError) { + retParam = { + DONE: 4, + HEADERS_RECEIVED: 2, + LOADING: 3, + OPENED: 1, + UNSENT: 0, + RESPONSE_TYPE_TEXT: "text", + RESPONSE_TYPE_ARRAYBUFFER: "arraybuffer", + RESPONSE_TYPE_BLOB: "blob", + RESPONSE_TYPE_DOCUMENT: "document", + RESPONSE_TYPE_JSON: "json", + RESPONSE_TYPE_STREAM: "stream", + toString: () => "[object Object]", // follow TM + ...resError, + } as GMXHRResponseType; + } else { + retParam = { + DONE: 4, + HEADERS_RECEIVED: 2, + LOADING: 3, + OPENED: 1, + UNSENT: 0, + RESPONSE_TYPE_TEXT: "text", + RESPONSE_TYPE_ARRAYBUFFER: "arraybuffer", + RESPONSE_TYPE_BLOB: "blob", + RESPONSE_TYPE_DOCUMENT: "document", + RESPONSE_TYPE_JSON: "json", + RESPONSE_TYPE_STREAM: "stream", + finalUrl: res.finalUrl as string, + readyState: res.readyState as 0 | 4 | 2 | 3 | 1, + status: res.status as number, + statusText: res.statusText as string, + responseHeaders: res.responseHeaders as string, + responseType: responseType as "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | "", + get response() { + if (response === false) { + switch (responseTypeOriginal) { + case "json": { + const text = this.responseText; + let o = undefined; + try { + o = JSON.parse(text); + } catch { + // ignored + } + response = o; // TM兼容 -> o : object | undefined + break; + } + case "document": { + response = this.responseXML; + break; + } + case "arraybuffer": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + response = full.buffer; // ArrayBuffer + break; + } + case "blob": { + finalResultBuffers ||= concatUint8(resultBuffers); + const full = finalResultBuffers; + const type = res.contentType || "application/octet-stream"; + response = new Blob([full], { type }); // Blob + break; + } + default: { + // text + response = `${this.responseText}`; + break; + } + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } + } + return response as string | ArrayBuffer | Blob | Document | ReadableStream | null; + }, + get responseXML() { + if (responseXML === false) { + const text = this.responseText; + if ( + ["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"].includes( + res.contentType + ) + ) { + responseXML = new DOMParser().parseFromString(text, res.contentType as DOMParserSupportedType); + } else { + responseXML = new DOMParser().parseFromString(text, "text/xml"); + } + } + return responseXML as Document | null; + }, + get responseText() { + if (responseTypeOriginal === "document") { + // console.log(resultType, resultBuffers.length, resultTexts.length); + } + if (responseText === false) { + if (resultType === 2) { + finalResultBuffers ||= concatUint8(resultBuffers); + const buf = finalResultBuffers.buffer as ArrayBuffer; + const decoder = new TextDecoder("utf-8"); + const text = decoder.decode(buf); + responseText = text; + } else { + // resultType === 3 + if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; + responseText = finalResultText; + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } + } + return responseText as string; + }, + toString: () => "[object Object]", // follow TM + } as GMXHRResponseType; + if (res.error) { + retParam.error = res.error; + } + if (responseType === "json" && retParam.response === null) { + response = undefined; // TM不使用null,使用undefined + } + } + if (typeof contentContext !== "undefined") { + retParam.context = contentContext; + } + return retParam; + }; + let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; + doAbort = (data: any) => { + if (!reqDone) { + errorOccur = "AbortError"; + details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); + reqDone = true; + refCleanup?.(); + } + doAbort = null; + }; + + let onMessageHandler: ((data: TMessage) => void) | null = (msgData: TMessage) => { + stackAsyncTask(asyncTaskId, async () => { + const data = msgData.data as Record & { + // + finalUrl: string; + readyState: 0 | 4 | 2 | 3 | 1; + status: number; + statusText: string; + responseHeaders: string; + // + useFetch: boolean; + eventType: string; + ok: boolean; + contentType: string; + error: undefined | string; + }; + if (msgData.code === -1) { + // 处理错误 + LoggerCore.logger().error("GM_xmlhttpRequest error", { + code: msgData.code, + message: msgData.message, + }); + details.onerror?.({ + readyState: 4, + error: msgData.message || "unknown", + }); + return; + } + // 处理返回 + switch (msgData.action) { + case "reset_chunk_arraybuffer": + case "reset_chunk_blob": + case "reset_chunk_buffer": { + resultBuffers.length = 0; + isEmptyResult = true; + break; + } + case "reset_chunk_document": + case "reset_chunk_json": + case "reset_chunk_text": { + resultTexts.length = 0; + isEmptyResult = true; + break; + } + case "append_chunk_stream": { + const d = msgData.data.chunk as string; + const u8 = base64ToUint8(d); + resultBuffers.push(u8); + isEmptyResult = false; + controller?.enqueue(base64ToUint8(d)); + resultType = 1; + break; + } + case "append_chunk_arraybuffer": + case "append_chunk_blob": + case "append_chunk_buffer": { + const d = msgData.data.chunk as string; + const u8 = base64ToUint8(d); + resultBuffers.push(u8); + isEmptyResult = false; + resultType = 2; + break; + } + case "append_chunk_document": + case "append_chunk_json": + case "append_chunk_text": { + const d = msgData.data.chunk as string; + resultTexts.push(d); + isEmptyResult = false; + resultType = 3; + break; + } + case "onload": + details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onloadend": { + reqDone = true; + responseText = false; + finalResultBuffers = null; + finalResultText = null; + const xhrReponse = makeXHRCallbackParam?.(data) ?? {}; + details.onloadend?.(xhrReponse); + if (errorOccur === null) { + retPromiseResolve?.(xhrReponse); + } else { + retPromiseReject?.(errorOccur); + } + refCleanup?.(); + break; + } + case "onloadstart": + details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onprogress": { + if (details.onprogress) { + if (!xhrType || xhrType === "text") { + responseText = false; // 设为false 表示需要更新。在 get setter 中更新 + response = false; // 设为false 表示需要更新。在 get setter 中更新 + responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 + } + const res = { + ...(makeXHRCallbackParam?.(data) ?? {}), + lengthComputable: data.lengthComputable as boolean, + loaded: data.loaded as number, + total: data.total as number, + done: data.loaded, + totalSize: data.total, + }; + details.onprogress?.(res); + } + break; + } + case "onreadystatechange": { + // 避免xhr的readystatechange多次触发问题。见 https://github.com/violentmonkey/violentmonkey/issues/1862 + const curStateAndCode = `${data.readyState}:${data.status}`; + if (curStateAndCode === lastStateAndCode) return; + lastStateAndCode = curStateAndCode; + if (data.readyState === 4) { + if (resultType === 1) { + // stream type + controller = undefined; // GC用 + } else if (resultType === 2) { + // buffer type + responseText = false; // 设为false 表示需要更新。在 get setter 中更新 + response = false; // 设为false 表示需要更新。在 get setter 中更新 + responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 + /* + if (xhrType === "blob") { + const full = concatUint8(resultBuffers); + const type = data.data.contentType || "application/octet-stream"; + response = new Blob([full], { type }); // Blob + if (responseTypeOriginal === "document") { + const blobURL = await toBlobURL(a, response as Blob); + const document = await urlToDocumentLocal(a, blobURL); + response = document; + responseXML = document; + } + } else if (xhrType === "arraybuffer") { + const full = concatUint8(resultBuffers); + response = full.buffer; // ArrayBuffer + } + */ + } else if (resultType === 3) { + // string type + + responseText = false; // 设为false 表示需要更新。在 get setter 中更新 + response = false; // 设为false 表示需要更新。在 get setter 中更新 + responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 + /* + if (xhrType === "json") { + const full = resultTexts.join(""); + try { + response = JSON.parse(full); + } catch { + response = null; + } + responseText = full; // XHR exposes responseText even for JSON + } else if (xhrType === "document") { + // 不应该出现 document type + console.error("ScriptCat: Invalid Calling in GM_xmlhttpRequest"); + responseText = ""; + response = null; + responseXML = null; + // const full = resultTexts.join(""); + // try { + // response = strToDocument(a, full, data.data.contentType as DOMParserSupportedType); + // } catch { + // response = null; + // } + // if (response) { + // responseXML = response; + // } + } else { + const full = resultTexts.join(""); + response = full; + responseText = full; + } + */ + } + } + details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + break; + } + case "ontimeout": + if (!reqDone) { + errorOccur = "TimeoutError"; + details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); + reqDone = true; + refCleanup?.(); + } + break; + case "onerror": + if (!reqDone) { + data.error ||= "Unknown Error"; + errorOccur = data.error; + details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); + reqDone = true; + refCleanup?.(); + } + break; + case "onabort": + doAbort?.(data); + break; + // case "onstream": + // controller?.enqueue(new Uint8Array(data)); + // break; + default: + LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", { + data: msgData, + }); + break; + } + }); + }; + + connect?.onMessage((msgData) => onMessageHandler?.(msgData)); + }); + }; + // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 + handler(); + return { + retPromise, + abort: () => { + if (connect) { + connect.disconnect(); + connect = null; + } + if (doAbort && details.onabort && !reqDone) { + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort + // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. + doAbort?.({ + error: "aborted", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + }) as GMXHRResponseType; + reqDone = true; + } + }, + }; +} diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts index b397fc918..b9ea11e8a 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -1,182 +1,14 @@ -import LoggerCore from "@App/app/logger/core"; -import Logger from "@App/app/logger/logger"; +import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; import type { IGetSender, Group } from "@Packages/message/server"; -import type { MessageConnect } from "@Packages/message/types"; export default class GMApi { - logger: Logger = LoggerCore.logger().with({ service: "gmApi" }); - constructor(private group: Group) {} - async dealXhrResponse( - con: MessageConnect | undefined, - details: GMSend.XHRDetails, - event: string, - xhr: XMLHttpRequest, - data?: any - ) { - if (!con) return; - const finalUrl = xhr.responseURL || details.url; - let response: GMTypes.XHRResponse = { - finalUrl, - readyState: xhr.readyState, - status: xhr.status, - statusText: xhr.statusText, - // header由service_worker处理,但是存在特殊域名(例如:edge.microsoft.com)无法获取的情况,在这里增加一个默认值 - responseHeaders: xhr.getAllResponseHeaders(), - responseType: details.responseType, - }; - if (xhr.readyState === 4) { - const responseType = details.responseType?.toLowerCase(); - if (responseType === "arraybuffer" || responseType === "blob") { - const xhrResponse = xhr.response; - if (xhrResponse === null) { - response.response = null; - } else { - let blob: Blob; - if (xhrResponse instanceof ArrayBuffer) { - blob = new Blob([xhrResponse]); - response.response = URL.createObjectURL(blob); - } else { - blob = xhrResponse; - response.response = URL.createObjectURL(blob); - } - try { - if (xhr.getResponseHeader("Content-Type")?.includes("text")) { - // 如果是文本类型,则尝试转换为文本 - response.responseText = await blob.text(); - } - } catch (e) { - LoggerCore.logger(Logger.E(e)).error("GM XHR getResponseHeader error"); - } - setTimeout(() => { - URL.revokeObjectURL(response.response); - }, 60 * 1000); - } - } else if (response.responseType === "json") { - try { - response.response = JSON.parse(xhr.responseText); - } catch (e) { - LoggerCore.logger(Logger.E(e)).error("GM XHR JSON parse error"); - } - try { - response.responseText = xhr.responseText; - } catch (e) { - LoggerCore.logger(Logger.E(e)).error("GM XHR getResponseText error"); - } - } else { - try { - response.response = xhr.response; - } catch (e) { - LoggerCore.logger(Logger.E(e)).error("GM XHR response error"); - } - try { - response.responseText = xhr.responseText || undefined; - } catch (e) { - LoggerCore.logger(Logger.E(e)).error("GM XHR getResponseText error"); - } - } - } - if (data) { - response = Object.assign(response, data); - } - con.sendMessage({ - action: event, - data: response, - }); - return response; - } - async xmlHttpRequest(details: GMSend.XHRDetails, sender: IGetSender) { - if (details.responseType === "stream") { - // 只有fetch支持ReadableStream - throw new Error("Method not implemented."); - } - const xhr = new XMLHttpRequest(); const con = sender.getConnect(); // con can be undefined - xhr.open(details.method || "GET", details.url, true, details.user || "", details.password || ""); - // 添加header - if (details.headers) { - for (const key in details.headers) { - xhr.setRequestHeader(key, details.headers[key]); - } - } - //超时时间 - if (details.timeout) { - xhr.timeout = details.timeout; - } - if (details.overrideMimeType) { - xhr.overrideMimeType(details.overrideMimeType); - } - //设置响应类型 - if (details.responseType !== "json") { - xhr.responseType = details.responseType || ""; - } - - xhr.onload = () => { - this.dealXhrResponse(con, details, "onload", xhr); - }; - xhr.onloadstart = () => { - this.dealXhrResponse(con, details, "onloadstart", xhr); - }; - xhr.onloadend = () => { - this.dealXhrResponse(con, details, "onloadend", xhr); - }; - xhr.onabort = () => { - this.dealXhrResponse(con, details, "onabort", xhr); - }; - xhr.onerror = () => { - this.dealXhrResponse(con, details, "onerror", xhr); - }; - xhr.onprogress = (event) => { - const respond: GMTypes.XHRProgress = { - done: xhr.DONE, - lengthComputable: event.lengthComputable, - loaded: event.loaded, - total: event.total, - totalSize: event.total, - }; - this.dealXhrResponse(con, details, "onprogress", xhr, respond); - }; - xhr.onreadystatechange = () => { - this.dealXhrResponse(con, details, "onreadystatechange", xhr); - }; - xhr.ontimeout = () => { - con?.sendMessage({ action: "ontimeout", data: {} }); - }; - //处理数据 - if (details.dataType === "FormData") { - const data = new FormData(); - if (details.data && details.data instanceof Array) { - await Promise.all( - details.data.map((val: GMSend.XHRFormData) => { - if (val.type === "file") { - return fetch(val.val) - .then((res) => res.blob()) - .then((blob) => { - const file = new File([blob], val.filename!); - data.append(val.key, file, val.filename); - }); - } else { - data.append(val.key, val.val); - } - }) - ); - xhr.send(data); - } - } else if (details.dataType === "Blob") { - if (!details.data) { - throw new Error("Blob data is empty"); - } - const resp = await (await fetch(details.data)).blob(); - xhr.send(resp); - } else { - xhr.send(details.data); - } - - con?.onDisconnect(() => { - xhr.abort(); - }); + if (!con) throw new Error("offscreen xmlHttpRequest: Connection is undefined"); + const bgGmXhr = new BgGMXhr(details, { statusCode: 0, finalUrl: "", responseHeaders: "" }, con); + bgGmXhr.do(); } textarea: HTMLTextAreaElement = document.createElement("textarea"); diff --git a/src/app/service/service_worker/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts similarity index 58% rename from src/app/service/service_worker/gm_api.test.ts rename to src/app/service/service_worker/gm_api/gm_api.test.ts index 3517fb3f3..efc9bdb76 100644 --- a/src/app/service/service_worker/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { type IGetSender } from "@Packages/message/server"; import { type ExtMessageSender } from "@Packages/message/types"; -import { isConnectMatched } from "./gm_api"; +import { ConnectMatch, getConnectMatched } from "./gm_api"; // 小工具:建立假的 IGetSender const makeSender = (url?: string): IGetSender => ({ @@ -15,39 +15,39 @@ const makeSender = (url?: string): IGetSender => ({ describe.concurrent("isConnectMatched", () => { it.concurrent("回传 false 当 metadataConnect 为 undefined 或空阵列", () => { const req = new URL("https://api.example.com/v1"); - expect(isConnectMatched(undefined, req, makeSender("https://app.example.com"))).toBe(false); - expect(isConnectMatched([], req, makeSender("https://app.example.com"))).toBe(false); + expect(getConnectMatched(undefined, req, makeSender("https://app.example.com"))).toBe(ConnectMatch.NONE); + expect(getConnectMatched([], req, makeSender("https://app.example.com"))).toBe(ConnectMatch.NONE); }); it.concurrent('遇到 "*" 应回传 true', () => { const req = new URL("https://anything.example.com/path"); - expect(isConnectMatched(["*"], req, makeSender())).toBe(true); + expect(getConnectMatched(["*"], req, makeSender())).toBe(ConnectMatch.ALL); }); it.concurrent("尾缀网域比对成功时回传 true(example.com 比对 api.example.com)", () => { const req = new URL("https://api.example.com/users"); - expect(isConnectMatched(["example.com"], req, makeSender())).toBe(true); - expect(isConnectMatched(["foo.com", "bar.net", "example.com"], req, makeSender())).toBe(true); - expect(isConnectMatched(["foo.com", "bar.net", "api.example.com"], req, makeSender())).toBe(true); - expect(isConnectMatched(["foo.com", "bar.net", "apiexample.com"], req, makeSender())).toBe(false); + expect(getConnectMatched(["example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); + expect(getConnectMatched(["foo.com", "bar.net", "example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); + expect(getConnectMatched(["foo.com", "bar.net", "api.example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); + expect(getConnectMatched(["foo.com", "bar.net", "apiexample.com"], req, makeSender())).toBe(ConnectMatch.NONE); }); it.concurrent("尾缀网域比对成功时回传 true(myapple.com vs apple.com)", () => { const req = new URL("https://myapple.com/users"); - expect(isConnectMatched(["myapple.com"], req, makeSender())).toBe(true); - expect(isConnectMatched(["apple.com"], req, makeSender())).toBe(false); + expect(getConnectMatched(["myapple.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); + expect(getConnectMatched(["apple.com"], req, makeSender())).toBe(ConnectMatch.NONE); }); it.concurrent('metadata 包含 "self" 且 sender.url 与 reqURL 主机相同时回传 true', () => { const req = new URL("https://app.example.com/dashboard"); const sender = makeSender("https://app.example.com/some-page"); - expect(isConnectMatched(["self"], req, sender)).toBe(true); + expect(getConnectMatched(["self"], req, sender)).toBe(ConnectMatch.SELF); }); it.concurrent('metadata 包含 "self" 但 sender.url 与 reqURL 主机不同时回传 false(若无其他规则命中)', () => { const req = new URL("https://api.example.com/resource"); const sender = makeSender("https://news.example.com/article"); - expect(isConnectMatched(["self"], req, sender)).toBe(false); + expect(getConnectMatched(["self"], req, sender)).toBe(ConnectMatch.NONE); }); it.concurrent( @@ -57,30 +57,30 @@ describe.concurrent("isConnectMatched", () => { // 无 url const senderNoUrl = makeSender(); - expect(isConnectMatched(["self"], req, senderNoUrl)).toBe(false); + expect(getConnectMatched(["self"], req, senderNoUrl)).toBe(ConnectMatch.NONE); // 无效 URL(try/catch 会吞掉错误) const senderBadUrl = makeSender("not a valid url"); - expect(isConnectMatched(["self"], req, senderBadUrl)).toBe(false); + expect(getConnectMatched(["self"], req, senderBadUrl)).toBe(ConnectMatch.NONE); } ); it.concurrent('当 "self" 不符合但尾缀规则符合时仍应回传 true(走到后续条件)', () => { const req = new URL("https://api.example.com/data"); const sender = makeSender("https://other.site.com/"); - expect(isConnectMatched(["self", "example.com"], req, sender)).toBe(true); + expect(getConnectMatched(["self", "example.com"], req, sender)).toBe(ConnectMatch.DOMAIN); }); it.concurrent("完全不匹配时回传 false", () => { const req = new URL("https://api.foo.com"); const sender = makeSender("https://bar.com"); - expect(isConnectMatched(["baz.com", "qux.net"], req, sender)).toBe(false); + expect(getConnectMatched(["baz.com", "qux.net"], req, sender)).toBe(ConnectMatch.NONE); }); it.concurrent("域名不区分大小写", () => { const req = new URL("https://API.Example.COM/Path"); - expect(isConnectMatched(["example.com"], req, makeSender())).toBe(true); - expect(isConnectMatched(["EXAMPLE.COM"], req, makeSender())).toBe(true); - expect(isConnectMatched(["Api.Example.com"], req, makeSender())).toBe(true); + expect(getConnectMatched(["example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); + expect(getConnectMatched(["EXAMPLE.COM"], req, makeSender())).toBe(ConnectMatch.DOMAIN); + expect(getConnectMatched(["Api.Example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN); }); }); diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts similarity index 60% rename from src/app/service/service_worker/gm_api.ts rename to src/app/service/service_worker/gm_api/gm_api.ts index a0c3fbb18..4e279a862 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1,17 +1,15 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; import { ScriptDAO } from "@App/app/repo/scripts"; -import { SenderConnect, type IGetSender, type Group, GetSenderType } from "@Packages/message/server"; -import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; +import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server"; +import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types"; import { connect, sendMessage } from "@Packages/message/client"; import type { IMessageQueue } from "@Packages/message/message_queue"; -import { MockMessageConnect } from "@Packages/message/mock_message"; import { type ValueService } from "@App/app/service/service_worker/value"; -import type { ConfirmParam } from "./permission_verify"; -import PermissionVerify, { PermissionVerifyApiGet } from "./permission_verify"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify"; import { cacheInstance } from "@App/app/cache"; -import EventEmitter from "eventemitter3"; -import { type RuntimeService } from "./runtime"; +import { type RuntimeService } from "../runtime"; import { getIcon, isFirefox, openInCurrentTab, cleanFileName } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; import i18next, { i18nName } from "@App/locales/locales"; @@ -26,22 +24,54 @@ import type { MessageRequest, NotificationMessageOption, GMApiRequest, -} from "./types"; -import type { TScriptMenuRegister, TScriptMenuUnregister } from "../queue"; -import { BrowserNoSupport, notificationsUpdate } from "./utils"; +} from "../types"; +import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; +import { BrowserNoSupport, notificationsUpdate } from "../utils"; import i18n from "@App/locales/locales"; import { decodeMessage, type TEncodedMessage } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; -import { createObjectURL } from "../offscreen/client"; +import { createObjectURL } from "../../offscreen/client"; +import type { GMXhrStrategy } from "./gm_xhr"; +import { + GMXhrFetchStrategy, + GMXhrXhrStrategy, + nwErrorResultPromises, + nwErrorResults, + redirectedUrls, + scXhrRequests, + SWRequestResultParams, +} from "./gm_xhr"; +import { headerModifierMap, headersReceivedMap } from "./gm_xhr"; +import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; -// GMApi,处理脚本的GM API调用请求 +const askUnlistedConnect = false; +const askConnectStar = true; -type RequestResultParams = { - requestId: number; - statusCode: number; - responseHeader: string; +let generatedUniqueMarkerIDs = ""; +let generatedUniqueMarkerIDWhen = ""; +// 用来生成绝不重复的 MarkerID +const generateUniqueMarkerID = () => { + const u1 = Math.floor(Date.now()).toString(36); + let u2 = `_${Math.floor(Math.random() * 2514670967279938 + 1045564536402193).toString(36)}`; + if (u1 !== generatedUniqueMarkerIDWhen) { + generatedUniqueMarkerIDWhen = u1; + generatedUniqueMarkerIDs = u2; + } else { + // 实际上 u2 的重复可能性非常低 + while (generatedUniqueMarkerIDs.indexOf(u2) >= 0) { + u2 = `_${Math.floor(Math.random() * 2514670967279938 + 1045564536402193).toString(36)}`; + } + generatedUniqueMarkerIDs += u2; + } + return `MARKER::${u1}${u2}`; }; +const enum xhrExtraCode { + INVALID_URL = 0x20, + DOMAIN_NOT_INCLUDED = 0x30, + DOMAIN_IN_BLACKLIST = 0x40, +} + type OnBeforeSendHeadersOptions = `${chrome.webRequest.OnBeforeSendHeadersOptions}`; type OnHeadersReceivedOptions = `${chrome.webRequest.OnHeadersReceivedOptions}`; @@ -49,6 +79,7 @@ type OnHeadersReceivedOptions = `${chrome.webRequest.OnHeadersReceivedOptions}`; // 为了支持外部依赖注入,方便测试和扩展 interface IGMExternalDependencies { emitEventToTab(to: ExtMessageSender, req: EmitEventRequest): void; + isBlacklistNetwork(url: URL): boolean; } /** @@ -106,7 +137,18 @@ export const checkHasUnsafeHeaders = (key: string) => { return false; }; -export const isConnectMatched = (metadataConnect: string[] | undefined, reqURL: URL, sender: IGetSender) => { +export enum ConnectMatch { + NONE = 0, + ALL = 1, + DOMAIN = 2, + SELF = 3, +} + +export const getConnectMatched = ( + metadataConnect: string[] | undefined, + reqURL: URL, + sender: IGetSender +): ConnectMatch => { if (metadataConnect?.length) { for (let i = 0, l = metadataConnect.length; i < l; i += 1) { const lowerMetaConnect = metadataConnect[i].toLowerCase(); @@ -120,15 +162,17 @@ export const isConnectMatched = (metadataConnect: string[] | undefined, reqURL: // ignore } if (senderURLObject) { - if (reqURL.hostname === senderURLObject.hostname) return true; + if (reqURL.hostname === senderURLObject.hostname) return ConnectMatch.SELF; } } - } else if (lowerMetaConnect === "*" || `.${reqURL.hostname}`.endsWith(`.${lowerMetaConnect}`)) { - return true; + } else if (lowerMetaConnect === "*") { + return ConnectMatch.ALL; + } else if (`.${reqURL.hostname}`.endsWith(`.${lowerMetaConnect}`)) { + return ConnectMatch.DOMAIN; } } } - return false; + return ConnectMatch.NONE; }; type NotificationData = { @@ -146,6 +190,13 @@ export class GMExternalDependencies implements IGMExternalDependencies { emitEventToTab(to: ExtMessageSender, req: EmitEventRequest): void { this.runtimeService.emitEventToTab(to, req); } + isBlacklistNetwork(url: URL) { + const isBlackListed = + this.runtimeService.isUrlBlacklist(url.href) || // 黑名单中含有该网址 https://abc.com/page.html + this.runtimeService.isUrlBlacklist(`${url.protocol}//${url.hostname}`) || // 黑名单中含有该网域 https://abc.com + this.runtimeService.isUrlBlacklist(`${url.protocol}//${url.hostname}/`); // 黑名单中含有该网域 https://abc.com/ + return isBlackListed; + } } export class MockGMExternalDependencies implements IGMExternalDependencies { @@ -153,8 +204,22 @@ export class MockGMExternalDependencies implements IGMExternalDependencies { // Mock implementation for testing console.log("Mock emitEventToTab called", { to, req }); } + isBlacklistNetwork(_url: URL) { + return false; + } } +const supportedRequestMethods = new Set([ + "connect", + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", +]); + export default class GMApi { logger: Logger; @@ -182,7 +247,7 @@ export default class GMApi { } const req = await this.parseRequest(data); try { - await this.permissionVerify.verify(req, api, sender); + await this.permissionVerify.verify(req, api, sender, this); } catch (e) { this.logger.error("verify error", { api: data.api }, Logger.E(e)); throw e; @@ -215,7 +280,7 @@ export default class GMApi { url.host = detail.domain || ""; url.hostname = detail.domain || ""; } - if (!isConnectMatched(request.script.metadata.connect, url, sender)) { + if (getConnectMatched(request.script.metadata.connect, url, sender) === 0) { throw new Error("hostname must be in the definition of connect"); } const metadata: { [key: string]: string } = {}; @@ -470,55 +535,55 @@ export default class GMApi { } } - // 有一些操作需要同步,就用Map作为缓存 - cache = new Map(); + // 根据header生成dnr规则 + async buildDNRRule(markerID: string, params: GMSend.XHRDetails, sender: IGetSender): Promise { + // 添加请求header + 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}`; - chromeSupportMethod = new Set(["connect", "delete", "get", "head", "options", "patch", "post", "put"]); + // 关联 reqID 方法 + // 1) 尝试在 onBeforeRequest 进行关连 + // 2) 如果在 chrome.webRequest.onBeforeSendHeaders 执行时,modifyHeaders DNR 未被执行,则以 "x-sc-request-marker" 进行关连 - // 根据header生成dnr规则 - async buildDNRRule( - reqeustId: number, - params: GMSend.XHRDetails, - sender: IGetSender - ): Promise<{ [key: string]: string }> { - const headers = params.headers || {}; // 如果header中没有origin就设置为空字符串,如果有origin就不做处理,注意处理大小写 - if (!("Origin" in headers) && !("origin" in headers)) { + if (typeof headers["Origin"] !== "string" && typeof headers["origin"] !== "string") { headers["Origin"] = ""; } - const requestHeaders = [ - { - header: "X-Scriptcat-GM-XHR-Request-Id", - operation: "remove", - }, - ] as chrome.declarativeNetRequest.ModifyHeaderInfo[]; + const modifyReqHeaders = [] as chrome.declarativeNetRequest.ModifyHeaderInfo[]; // 判断是否是anonymous - if (params.anonymous) { + if (anonymous) { // 如果是anonymous,并且有cookie,则设置为自定义的cookie - if (params.cookie) { - requestHeaders.push({ + if (cookie) { + modifyReqHeaders.push({ header: "cookie", operation: "set", - value: params.cookie, + value: cookie, }); } else { // 否则删除cookie - requestHeaders.push({ + modifyReqHeaders.push({ header: "cookie", operation: "remove", }); } } else { - if (params.cookie) { + if (cookie) { // 否则正常携带cookie header - headers["cookie"] = params.cookie; + headers["cookie"] = cookie; } // 追加该网站本身存储的cookie const tabId = sender.getExtMessageSender().tabId; let storeId: string | undefined; - if (tabId !== -1) { + if (tabId !== -1 && typeof tabId === "number") { const stores = await chrome.cookies.getAllCookieStores(); const store = stores.find((val) => val.tabIds.includes(tabId)); if (store) { @@ -537,196 +602,98 @@ export default class GMApi { partitionKey: params.cookiePartition, }); // 追加cookie - if (cookies.length) { - const cookieStr = cookies.map((c) => `${c.name}=${c.value}`).join("; "); - if (!("cookie" in headers)) { - headers.cookie = ""; - } - headers["cookie"] = headers["cookie"].trim(); - if (headers["cookie"] === "") { - // 空的 - headers["cookie"] = cookieStr; - } else { - // 非空 - if (!headers["cookie"].endsWith(";")) { - headers["cookie"] = headers["cookie"] + "; "; - } - headers["cookie"] = headers["cookie"] + cookieStr; - } + if (cookies?.length) { + const v = cookies.map((c) => `${c.name}=${c.value}`).join("; "); + const u = `${headers["cookie"] || ""}`.trim(); + headers["cookie"] = u ? `${u}${!u.endsWith(";") ? "; " : " "}${v}` : v; } } - for (const key of Object.keys(headers)) { - /** 请求的header的值 */ - const headerValue = headers[key]; - let deleteHeader = false; - if (headerValue) { - if (checkHasUnsafeHeaders(key)) { - requestHeaders.push({ - header: key, - operation: "set", - value: headerValue.toString(), - }); - deleteHeader = true; - } - } else { - requestHeaders.push({ + /** 请求的header的值 */ + for (const [key, headerValue] of Object.entries(headers)) { + if (!headerValue) { + modifyReqHeaders.push({ header: key, operation: "remove", }); - deleteHeader = true; + delete headers[key]; + } else if (checkHasUnsafeHeaders(key)) { + modifyReqHeaders.push({ + header: key, + operation: "set", + value: `${headerValue}`, + }); + delete headers[key]; } - deleteHeader && delete headers[key]; } - const rule = {} as chrome.declarativeNetRequest.Rule; - rule.id = reqeustId; - rule.action = { - type: "modifyHeaders", - requestHeaders: requestHeaders, - }; - rule.priority = 1; - const tabs = await chrome.tabs.query({}); - const excludedTabIds: number[] = []; - for (const tab of tabs) { - if (tab.id) { - excludedTabIds.push(tab.id); + if (modifyReqHeaders.length > 0) { + // const tabs = await chrome.tabs.query({}); + // const excludedTabIds: number[] = []; + // for (const tab of tabs) { + // if (tab.id) { + // excludedTabIds.push(tab.id); + // } + // } + let requestMethod = (params.method || "GET").toLowerCase() as chrome.declarativeNetRequest.RequestMethod; + if (!supportedRequestMethods.has(requestMethod)) { + requestMethod = "other" as chrome.declarativeNetRequest.RequestMethod; } - } - let requestMethod = (params.method || "GET").toLowerCase() as chrome.declarativeNetRequest.RequestMethod; - if (!this.chromeSupportMethod.has(requestMethod)) { - requestMethod = "other" as chrome.declarativeNetRequest.RequestMethod; - } - rule.condition = { - resourceTypes: ["xmlhttprequest"], - urlFilter: params.url, - requestMethods: [requestMethod], - excludedTabIds: excludedTabIds, - }; - this.cache.set("dnrRule:" + reqeustId.toString(), rule); - await chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [reqeustId], - addRules: [rule], - }); - return headers; - } - - gmXhrHeadersReceived = new EventEmitter(); + const redirectNotManual = params.redirect !== "manual"; - dealFetch( - config: GMSend.XHRDetails, - response: Response, - readyState: 0 | 1 | 2 | 3 | 4, - resultParam?: RequestResultParams - ) { - let respHeader = ""; - response.headers.forEach((value, key) => { - respHeader += `${key}: ${value}\n`; - }); - const respond: GMTypes.XHRResponse = { - finalUrl: response.url || config.url, - readyState, - status: response.status, - statusText: response.statusText, - responseHeaders: respHeader, - responseType: config.responseType, - }; - if (resultParam) { - respond.status = respond.status || resultParam.statusCode; - respond.responseHeaders = resultParam.responseHeader || respond.responseHeaders; + // 使用 cacheInstance 避免SW重启造成重复 DNR Rule ID + const ruleId = 10000 + (await cacheInstance.incr("gmXhrRequestId", 1)); + const rule = { + id: ruleId, + action: { + type: "modifyHeaders", + requestHeaders: modifyReqHeaders, + }, + priority: 1, + condition: { + resourceTypes: ["xmlhttprequest"], + urlFilter: params.url, + requestMethods: [requestMethod], + // excludedTabIds: excludedTabIds, + tabIds: [chrome.tabs.TAB_ID_NONE], // 只限于后台 service_worker / offscreen + }, + } as chrome.declarativeNetRequest.Rule; + headerModifierMap.set(markerID, { rule, redirectNotManual }); + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [ruleId], + addRules: [rule], + }); } - return respond; + return true; } - CAT_fetch(config: GMSend.XHRDetails, con: IGetSender, resultParam: RequestResultParams) { - const { url } = config; - const msgConn = con.getConnect(); - if (!msgConn) { - throw new Error("CAT_fetch ERROR: msgConn is undefinded"); - } - return fetch(url, { - method: config.method || "GET", - body: config.data, - headers: config.headers, - redirect: config.redirect, - signal: config.timeout ? AbortSignal.timeout(config.timeout) : undefined, - }).then((resp) => { - let send = this.dealFetch(config, resp, 1); - switch (resp.type) { - case "opaqueredirect": - // 处理manual重定向 - msgConn.sendMessage({ - action: "onloadstart", - data: send, - }); - send = this.dealFetch(config, resp, 2, resultParam); - msgConn.sendMessage({ - action: "onreadystatechange", - data: send, - }); - send.readyState = 4; - msgConn.sendMessage({ - action: "onreadystatechange", - data: send, - }); - msgConn.sendMessage({ - action: "onload", - data: send, - }); - msgConn.sendMessage({ - action: "onloadend", - data: send, - }); - return; + @PermissionVerify.API({ + confirm: async (request: GMApiRequest<[GMSend.XHRDetails]>, sender: IGetSender, GMApiInstance: GMApi) => { + const config = request.params[0]; + let url; + try { + url = new URL(config.url); + } catch { + request.extraCode = xhrExtraCode.INVALID_URL; + return false; } - const reader = resp.body?.getReader(); - if (!reader) { - throw new Error("read is not found"); + if (GMApiInstance.gmExternalDependencies.isBlacklistNetwork(url)) { + request.extraCode = xhrExtraCode.DOMAIN_IN_BLACKLIST; + return false; } - const readData = ({ done, value }: { done: boolean; value?: Uint8Array }) => { - if (done) { - const data = this.dealFetch(config, resp, 4, resultParam); - data.responseHeaders = resultParam.responseHeader || data.responseHeaders; - msgConn.sendMessage({ - action: "onreadystatechange", - data: data, - }); - msgConn.sendMessage({ - action: "onload", - data: data, - }); - msgConn.sendMessage({ - action: "onloadend", - data: data, - }); - } else { - msgConn.sendMessage({ - action: "onstream", - data: Array.from(value!), - }); - reader.read().then(readData); + const connectMatched = getConnectMatched(request.script.metadata.connect, url, sender); + if (connectMatched === 1) { + if (!askConnectStar) { + return true; + } + } else { + if (connectMatched > 0) { + return true; + } + if (!askUnlistedConnect && request.script.metadata.connect?.find((e) => !!e)) { + request.extraCode = xhrExtraCode.DOMAIN_NOT_INCLUDED; + return false; } - }; - reader.read().then(readData); - send.responseHeaders = resultParam.responseHeader || send.responseHeaders; - msgConn.sendMessage({ - action: "onloadstart", - data: send, - }); - send.readyState = 2; - msgConn.sendMessage({ - action: "onreadystatechange", - data: send, - }); - }); - } - - @PermissionVerify.API({ - confirm: async (request: GMApiRequest<[GMSend.XHRDetails]>, sender: IGetSender) => { - const config = request.params[0]; - const url = new URL(config.url); - if (isConnectMatched(request.script.metadata.connect, url, sender)) { - return true; } const metadata: { [key: string]: string } = {}; metadata[i18next.t("script_name")] = i18nName(request.script); @@ -746,77 +713,153 @@ export default class GMApi { alias: ["GM.xmlHttpRequest"], }) async GM_xmlhttpRequest(request: GMApiRequest<[GMSend.XHRDetails?]>, sender: IGetSender) { - const param1 = request.params[0]; - if (!param1) { - throw new Error("param is failed"); + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("GM_xmlhttpRequest ERROR: sender is not MessageConnect"); } - // 先处理unsafe hearder - // 关联自己生成的请求id与chrome.webRequest的请求id - const requestId = 10000 + (await cacheInstance.incr("gmXhrRequestId", 1)); - // 添加请求header - if (!param1.headers) { - param1.headers = {}; + const msgConn = sender.getConnect(); + if (!msgConn) { + throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined"); } + let isConnDisconnected = false; + msgConn.onDisconnect(() => { + isConnDisconnected = true; + }); - // 处理cookiePartition - if (!param1.cookiePartition || typeof param1.cookiePartition !== "object") { - param1.cookiePartition = {}; - } - if (typeof param1.cookiePartition.topLevelSite !== "string") { - // string | undefined - param1.cookiePartition.topLevelSite = undefined; - } + // 关联自己生成的请求id与chrome.webRequest的请求id + // 随机生成(同步),不需要 chrome.storage 存取 + const markerID = generateUniqueMarkerID(); - param1.headers["X-Scriptcat-GM-XHR-Request-Id"] = requestId.toString(); - param1.headers = await this.buildDNRRule(requestId, param1, sender); - const resultParam: RequestResultParams = { - requestId, - statusCode: 0, - responseHeader: "", - }; - let finalUrl = ""; - // 等待response - this.cache.set("gmXhrRequest:params:" + requestId, { - redirect: param1.redirect, - }); - this.gmXhrHeadersReceived.addListener( - "headersReceived:" + requestId, - (details: chrome.webRequest.OnHeadersReceivedDetails) => { - details.responseHeaders?.forEach((header) => { - resultParam.responseHeader += header.name + ": " + header.value + "\n"; + const resultParam = new SWRequestResultParams(markerID); + + const throwErrorFn = (error: string) => { + if (!isConnDisconnected) { + msgConn.sendMessage({ + action: "onerror", + data: { + status: resultParam.statusCode, + responseHeaders: resultParam.responseHeaders, + error: `${error}`, + readyState: 4, // ERROR. DONE. + }, }); - resultParam.statusCode = details.statusCode; - finalUrl = this.cache.get("gmXhrRequest:finalUrl:" + requestId); - this.gmXhrHeadersReceived.removeAllListeners("headersReceived:" + requestId); } - ); - if (param1.responseType === "stream" || param1.fetch || param1.redirect) { - // 只有fetch支持ReadableStream、redirect这些,直接使用fetch - return this.CAT_fetch(param1, sender, resultParam); + return new Error(`${error}`); + }; + + const details = request.params[0]; + if (!details) { + throw throwErrorFn("param is failed"); } - if (!sender.isType(GetSenderType.CONNECT)) { - throw new Error("GM_xmlhttpRequest ERROR: sender is not MessageConnect"); + + if (request.extraCode === xhrExtraCode.INVALID_URL) { + const msg = `Refused to connect to "${details.url}": The url is invalid`; + throw throwErrorFn(msg); } - const msgConn = sender.getConnect(); - if (!msgConn) { - throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined"); + if (request.extraCode === xhrExtraCode.DOMAIN_NOT_INCLUDED) { + // 'Refused to connect to "https://nonexistent-domain-abcxyz.test/": This domain is not a part of the @connect list' + const msg = `Refused to connect to "${details.url}": This domain is not a part of the @connect list`; + throw throwErrorFn(msg); } - // 再发送到offscreen, 处理请求 - const offscreenCon = await connect(this.msgSender, "offscreen/gmApi/xmlHttpRequest", param1); - offscreenCon.onMessage((msg) => { - // 发送到content - // 替换msg.data.responseHeaders - msg.data.responseHeaders = resultParam.responseHeader || msg.data.responseHeaders; - // 替换finalUrl - if (finalUrl) { - msg.data.finalUrl = finalUrl; + if (request.extraCode === xhrExtraCode.DOMAIN_IN_BLACKLIST) { + // 'Refused to connect to "https://example.org/": URL is blacklisted' + const msg = `Refused to connect to "${details.url}": URL is blacklisted`; + throw throwErrorFn(msg); + } + try { + /* + There are TM-specific parameters: + - cookie a cookie to be patched into the sent cookie set + - cookiePartition object?, containing the partition key to be used for sent and received partitioned cookies + topLevelSite string?, representing the top frame site for partitioned cookies + The ScriptCat implementation for cookie, cookiePartition, cookiePartition.topLevelSite are limited. + */ + + // 处理cookiePartition + // 详见 https://github.com/scriptscat/scriptcat/issues/392 + // https://github.com/scriptscat/scriptcat/commit/3774aa3acebeadb6b08162625a9af29a9599fa96 + // cookiePartition shall refers to the following issue: + // https://github.com/Tampermonkey/tampermonkey/issues/2419 + if (!details.cookiePartition || typeof details.cookiePartition !== "object") { + details.cookiePartition = {}; } - msgConn.sendMessage(msg); - }); - msgConn.onDisconnect(() => { - // 关闭连接 - offscreenCon.disconnect(); - }); + if (typeof details.cookiePartition.topLevelSite !== "string") { + // string | undefined + details.cookiePartition.topLevelSite = undefined; + } + + // 添加请求header, 处理unsafe hearder + await this.buildDNRRule(markerID, details, sender); + // let finalUrl = ""; + // 等待response + + let useFetch; + { + const anonymous = details.anonymous ?? details.mozAnon ?? false; + + const redirect = details.redirect; + + const isFetch = details.fetch ?? false; + + const isBufferStream = details.responseType === "stream"; + + useFetch = isFetch || !!redirect || anonymous || isBufferStream; + } + const loadendCleanUp = () => { + redirectedUrls.delete(markerID); + nwErrorResults.delete(markerID); + const reqId = scXhrRequests.get(markerID); + if (reqId) scXhrRequests.delete(reqId); + scXhrRequests.delete(markerID); + headersReceivedMap.delete(markerID); + headerModifierMap.delete(markerID); + }; + let strategy: GMXhrStrategy | undefined = undefined; + if (useFetch) { + strategy = new GMXhrFetchStrategy(details, resultParam); + } else if (typeof XMLHttpRequest === "function") { + // No offscreen in Firefox, but Firefox background script itself provides XMLHttpRequest. + // Firefox 中没有 offscreen,但 Firefox 的"后台脚本"本身提供了 XMLHttpRequest。 + strategy = new GMXhrXhrStrategy(resultParam); + } + if (strategy) { + const bgGmXhr = new BgGMXhr(details, resultParam, msgConn, strategy); + bgGmXhr.onLoaded(loadendCleanUp); + bgGmXhr.do(); + } else { + // 再发送到offscreen, 处理请求 + const offscreenCon = await connect(this.msgSender, "offscreen/gmApi/xmlHttpRequest", details); + offscreenCon.onMessage((msg) => { + // 发送到content + let data = msg.data; + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { + resultParam.resultParamStatusCode = msg.data.status; + } + data = { + ...data, + finalUrl: resultParam.finalUrl, // 替换finalUrl + responseHeaders: resultParam.responseHeaders || data.responseHeaders || "", // 替换msg.data.responseHeaders + status: resultParam.statusCode || data.statusCode || data.status, + }; + msg = { + action: msg.action, + data: data, + } as TMessageCommAction; + if (msg.action === "onloadend") { + loadendCleanUp(); + } + if (!isConnDisconnected) { + msgConn.sendMessage(msg); + } + }); + msgConn.onDisconnect(() => { + // 关闭连接 + offscreenCon.disconnect(); + }); + } + } catch (e: any) { + throw throwErrorFn(`GM_xmlhttpRequest ERROR: ${e?.message || e || "Unknown Error"}`); + } } @PermissionVerify.API({ alias: ["CAT_registerMenuInput"] }) @@ -1069,7 +1112,7 @@ export default class GMApi { } @PermissionVerify.API() - async GM_download(request: GMApiRequest<[GMTypes.DownloadDetails]>, sender: IGetSender) { + async GM_download(request: GMApiRequest<[GMTypes.DownloadDetails]>, sender: IGetSender) { if (!sender.isType(GetSenderType.CONNECT)) { throw new Error("GM_download ERROR: sender is not MessageConnect"); } @@ -1077,94 +1120,105 @@ export default class GMApi { if (!msgConn) { throw new Error("GM_download ERROR: msgConn is undefined"); } + let reqCompleteWith = ""; + let cDownloadId = 0; + let isConnDisconnected = false; const params = request.params[0]; // 替换掉windows下文件名的非法字符为 - const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 - if (params.url.startsWith("blob:") || params.downloadMode === "browser") { - chrome.downloads.download( - { - url: params.url, - saveAs: params.saveAs, - filename: fileName, - }, - () => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.downloads.download:", lastError); - // 下载API出现问题但继续执行 - } - msgConn.sendMessage({ action: "onload" }); - } - ); - return; - } - // 使用xhr下载blob,再使用download api创建下载 - const EE = new EventEmitter(); - const mockConnect = new MockMessageConnect(EE); - EE.addListener("message", (data: any) => { - const xhr = data.data; - const respond: any = { - finalUrl: xhr.url, - readyState: xhr.readyState, - status: xhr.status, - statusText: xhr.statusText, - responseHeaders: xhr.responseHeaders, - }; - switch (data.action) { - case "onload": + const blobURL = params.url; + const respond = null; + const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.downloads.onChanged:", lastError); + return; + } + if (!cDownloadId || downloadDelta.id !== cDownloadId) return; + if (downloadDelta.state?.current === "complete") { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "ok"; msgConn.sendMessage({ action: "onload", data: respond, }); - chrome.downloads.download({ - url: xhr.response, - saveAs: params.saveAs, - filename: fileName, - }); - break; - case "onerror": + } + chrome.downloads.onChanged.removeListener(onChangedListener); + } else if (downloadDelta.state?.current === "interrupted") { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "interrupted"; msgConn.sendMessage({ action: "onerror", data: respond, }); - break; - case "onprogress": - respond.done = xhr.done; - respond.lengthComputable = xhr.lengthComputable; - respond.loaded = xhr.loaded; - respond.total = xhr.total; - respond.totalSize = xhr.total; + } + chrome.downloads.onChanged.removeListener(onChangedListener); + } + }; + msgConn.onDisconnect(() => { + if (isConnDisconnected) return; + isConnDisconnected = true; + if (cDownloadId > 0 && !reqCompleteWith) { + reqCompleteWith = "disconnected"; + chrome.downloads.cancel(cDownloadId, () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.downloads.cancel:", lastError); + } + }); + chrome.downloads.onChanged.removeListener(onChangedListener); + } + }); + if (!blobURL) { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "error:no_blob_url"; + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); + } + throw new Error("GM_download ERROR: blobURL is not provided."); + } + const downloadAPIOptions = { + url: blobURL, + } as chrome.downloads.DownloadOptions; + if (typeof fileName === "string" && fileName) { + downloadAPIOptions.filename = fileName; + } + if (typeof params.saveAs === "boolean") { + downloadAPIOptions.saveAs = params.saveAs; + } + if (typeof params.conflictAction === "string") { + downloadAPIOptions.conflictAction = params.conflictAction; + } + chrome.downloads.onChanged.addListener(onChangedListener); + chrome.downloads.download(downloadAPIOptions, (downloadId: number | undefined) => { + const lastError = chrome.runtime.lastError; + let ok = true; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.downloads.download:", lastError); + // 下载API出现问题但继续执行 + ok = false; + } + if (downloadId === undefined) { + console.error("GM_download ERROR: API Failure for chrome.downloads.download."); + ok = false; + } + if (ok) { + cDownloadId = downloadId as number; + } + if (!ok) { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "error:download_api_error"; msgConn.sendMessage({ - action: "onprogress", + action: "onerror", data: respond, }); - break; - case "ontimeout": - msgConn.sendMessage({ - action: "ontimeout", - }); - break; + } + chrome.downloads.onChanged.removeListener(onChangedListener); } }); - return this.GM_xmlhttpRequest( - { - ...request, - params: [ - // 处理参数问题 - { - method: params.method || "GET", - url: params.url, - headers: params.headers, - timeout: params.timeout, - cookie: params.cookie, - anonymous: params.anonymous, - responseType: "blob", - } as GMSend.XHRDetails, - ], - }, - new SenderConnect(mockConnect) - ); } @PermissionVerify.API() @@ -1250,12 +1304,98 @@ export default class GMApi { // 处理GM_xmlhttpRequest请求 handlerGmXhr() { + chrome.webRequest.onBeforeRedirect.addListener( + (details) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRedirect:", lastError); + // webRequest API 出错不进行后续处理 + return undefined; + } + if (details.tabId === -1) { + const markerID = scXhrRequests.get(details.requestId); + if (markerID) { + redirectedUrls.set(markerID, details.redirectUrl); + } + } + }, + { + urls: [""], + types: ["xmlhttprequest"], + tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen + } + ); const reqOpt: OnBeforeSendHeadersOptions[] = ["requestHeaders"]; const respOpt: OnHeadersReceivedOptions[] = ["responseHeaders"]; if (!isFirefox()) { reqOpt.push("extraHeaders"); respOpt.push("extraHeaders"); } + + chrome.webRequest.onErrorOccurred.addListener( + (details) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webRequest.onErrorOccurred:", lastError); + // webRequest API 出错不进行后续处理 + return undefined; + } + if (details.tabId === -1) { + const markerID = scXhrRequests.get(details.requestId); + if (markerID) { + nwErrorResults.set(markerID, details.error); + nwErrorResultPromises.get(markerID)?.(); + } + } + }, + { + urls: [""], + types: ["xmlhttprequest"], + tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen + } + ); + + /* + + + + // 1) Network-level errors (DNS/TLS/connection/aborts) + chrome.webRequest.onErrorOccurred.addListener((details) => { + // Examples: net::ERR_NAME_NOT_RESOLVED, net::ERR_CONNECTION_REFUSED, net::ERR_ABORTED + console.warn("[NET ERROR]", { + url: details.url, + error: details.error, + type: details.type, // main_frame, xmlhttprequest, fetch, etc. + ip: details.ip, + fromCache: details.fromCache, + initiator: details.initiator, // who started it (tab/page/extension) + tabId: details.tabId + }); + }, { urls: [""] }); + + // 2) Inspect responses to spot CORS issues + chrome.webRequest.onHeadersReceived.addListener((details) => { + const headers = Object.fromEntries( + (details.responseHeaders || []).map(h => [h.name.toLowerCase(), h.value || ""]) + ); + + // If this was a cross-origin XHR/fetch, check for ACAO/ACAC headers. + // (You can refine with details.initiator, tabId, and compare URL origins.) + const hasACAO = "access-control-allow-origin" in headers; + const hasACAC = "access-control-allow-credentials" in headers; + + if (!hasACAO) { + console.info("[POSSIBLE CORS BLOCK]", { + url: details.url, + statusCode: details.statusCode, + missing: "Access-Control-Allow-Origin", + initiator: details.initiator, + tabId: details.tabId + }); + } + }, { urls: [""] }, ["responseHeaders"]); + + */ chrome.webRequest.onBeforeSendHeaders.addListener( (details) => { const lastError = chrome.runtime.lastError; @@ -1265,20 +1405,23 @@ export default class GMApi { return undefined; } if (details.tabId === -1) { - // 判断是否存在X-Scriptcat-GM-XHR-Request-Id - // 讲请求id与chrome.webRequest的请求id关联 - if (details.requestHeaders) { - const requestId = details.requestHeaders.find((header) => header.name === "X-Scriptcat-GM-XHR-Request-Id"); - if (requestId) { - this.cache.set("gmXhrRequest:" + details.requestId, requestId.value); - } + const reqId = details.requestId; + const requestHeaders = details.requestHeaders; + if (requestHeaders) { + // 如 onBeforeSendHeaders 是在 modifyHeaders 前执行,可以更新一下 reqId 和 markerID 的关联 + const markerID = requestHeaders?.find((h) => h.name.toLowerCase() === "x-sc-request-marker")?.value; + if (markerID) scXhrRequests.set(reqId, markerID); } + const markerID = scXhrRequests.get(reqId); + if (!markerID) return undefined; + redirectedUrls.set(markerID, details.url); } return undefined; }, { urls: [""], types: ["xmlhttprequest"], + tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen }, reqOpt ); @@ -1291,56 +1434,84 @@ export default class GMApi { return undefined; } if (details.tabId === -1) { + const reqId = details.requestId; + + const markerID = scXhrRequests.get(reqId); + if (!markerID) return; + headersReceivedMap.set(markerID, { + responseHeaders: details.responseHeaders, + statusCode: details.statusCode, + }); + // 判断请求是否与gmXhrRequest关联 - const requestId = this.cache.get("gmXhrRequest:" + details.requestId); - if (requestId) { + const dnrRule = headerModifierMap.get(markerID); + if (dnrRule) { + const { rule, redirectNotManual } = dnrRule; // 判断是否重定向 let location = ""; details.responseHeaders?.forEach((header) => { - if (header.name.toLowerCase() === "location") { + if (header?.name?.length === 8 && header.name.toLowerCase() === "location" && header.value?.length) { // 重定向 - if (header.value) { - try { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Location - // May be relative to the request URL or an absolute URL. - const url = new URL(header.value, details.url); - if (url.href) { - location = url.href; - } - } catch { - // ignore + try { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Location + // May be relative to the request URL or an absolute URL. + const url = new URL(header.value, details.url); + if (url.href) { + location = url.href; } + } catch { + // ignore } } }); - const params = this.cache.get("gmXhrRequest:params:" + requestId) as GMSend.XHRDetails; + // 如果是重定向,并且不是manual模式,则需要重新设置dnr规则 - if (location && params.redirect !== "manual") { + if (location && redirectNotManual) { // 处理重定向后的unsafeHeader - const rule = this.cache.get("dnrRule:" + requestId) as chrome.declarativeNetRequest.Rule; - // 修改匹配链接 - rule.condition.urlFilter = location; - // 不处理cookie - rule.action.requestHeaders = rule.action.requestHeaders?.filter( - (header) => header.header.toLowerCase() !== "cookie" + // 使用 object clone 避免 DNR API 新旧rule冲突 + const newRule = { + ...rule, + condition: { + ...rule.condition, + // 修改匹配链接 + urlFilter: location, + }, + action: { + ...rule.action, + // 不处理cookie + requestHeaders: rule.action.requestHeaders?.filter( + (header) => header.header.toLowerCase() !== "cookie" + ), + }, + }; + headerModifierMap.set(markerID, { rule: newRule, redirectNotManual }); + chrome.declarativeNetRequest.updateSessionRules( + { + removeRuleIds: [rule.id], + addRules: [newRule], + }, + () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); + } + } + ); + } else { + // 删除关联与DNR + headerModifierMap.delete(markerID); + chrome.declarativeNetRequest.updateSessionRules( + { + removeRuleIds: [rule.id], + }, + () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); + } + } ); - // 设置重定向url,获取到实际的请求地址 - this.cache.set("gmXhrRequest:finalUrl:" + requestId, location); - chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [parseInt(requestId)], - addRules: [rule], - }); - return; } - this.gmXhrHeadersReceived.emit("headersReceived:" + requestId, details); - // 删除关联与DNR - this.cache.delete("gmXhrRequest:" + details.requestId); - this.cache.delete("dnrRule:" + requestId); - this.cache.delete("gmXhrRequest:finalUrl:" + requestId); - this.cache.delete("gmXhrRequest:params:" + requestId); - chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [parseInt(requestId)], - }); } } return undefined; @@ -1351,6 +1522,37 @@ 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 new file mode 100644 index 000000000..62bf038d3 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_xhr.ts @@ -0,0 +1,129 @@ +import { type TMessageCommAction } from "@Packages/message/types"; + +export const scXhrRequests = new Map(); // 关联SC后台发出的 xhr/fetch 的 requestId +export const redirectedUrls = new Map(); // 关联SC后台发出的 xhr/fetch 的 redirectUrl +export const nwErrorResults = new Map(); // 关联SC后台发出的 xhr/fetch 的 network error +export const nwErrorResultPromises = new Map(); +// net::ERR_NAME_NOT_RESOLVED, net::ERR_CONNECTION_REFUSED, net::ERR_ABORTED, net::ERR_FAILED + +// 接收 xhr/fetch 的 responseHeaders +export const headersReceivedMap = new Map< + string, + { responseHeaders: chrome.webRequest.HttpHeader[] | undefined | null; statusCode: number | null } +>(); +// 特殊方式处理:以 DNR Rule per request 方式处理 header 修改 (e.g. cookie, unsafeHeader) +export const headerModifierMap = new Map< + string, + { + rule: chrome.declarativeNetRequest.Rule; + redirectNotManual: boolean; + } +>(); + +export class SWRequestResultParams { + resultParamFinalUrl: string = ""; + resultParamStatusCode: number = 0; + resultParamResponseHeader: string = ""; + + constructor(public markerID: string) {} + + get statusCode() { + const responsed = headersReceivedMap.get(this.markerID); + if (responsed && typeof responsed.statusCode === "number") { + this.resultParamStatusCode = responsed.statusCode; + responsed.statusCode = null; // 设为 null 避免重复处理 + } + return this.resultParamStatusCode; + } + + get responseHeaders() { + const responsed = headersReceivedMap.get(this.markerID); + if (responsed && responsed.responseHeaders) { + let s = ""; + for (const h of responsed.responseHeaders) { + s += `${h.name}: ${h.value}\n`; + } + this.resultParamResponseHeader = s; + responsed.responseHeaders = null; // 设为 null 避免重复处理 + } + return this.resultParamResponseHeader; + } + + get finalUrl() { + this.resultParamFinalUrl = redirectedUrls.get(this.markerID) || ""; + return this.resultParamFinalUrl; + } +} + +export interface GMXhrStrategy { + fixMsg( + msg: TMessageCommAction<{ + finalUrl: any; + responseHeaders: any; + readyState: 0 | 1 | 2 | 3 | 4; + status: number; + statusText: string; + useFetch: boolean; + eventType: string; + ok: boolean; + contentType: string; + error: string | undefined; + }> + ): Promise; +} + +// fetch策略 +export class GMXhrFetchStrategy implements GMXhrStrategy { + protected requestUrl: string = ""; + + public isRedirectError: boolean; + + constructor( + protected details: GMSend.XHRDetails, + protected resultParam: SWRequestResultParams + ) { + this.requestUrl = details.url; + this.isRedirectError = details.redirect === "error"; + } + + async fixMsg(msg: TMessageCommAction) { + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && this.resultParam.statusCode > 0 && this.resultParam.statusCode !== msg.data?.status) { + this.resultParam.resultParamStatusCode = msg.data.status; + } + if (msg.data?.status === 301) { + // 兼容TM - redirect: manual 显示原网址 + redirectedUrls.delete(this.resultParam.markerID); + this.resultParam.resultParamFinalUrl = this.requestUrl; + msg.data.finalUrl = this.requestUrl; + } else if (msg.action === "onerror" && this.isRedirectError && msg.data) { + let nwErr = nwErrorResults.get(this.resultParam.markerID); + if (!nwErr) { + // 等 Network Error 捕捉 + await Promise.race([ + new Promise((resolve) => { + nwErrorResultPromises.set(this.resultParam.markerID, resolve); + }), + new Promise((r) => setTimeout(r, 800)), + ]); + nwErr = nwErrorResults.get(this.resultParam.markerID); + } + if (nwErr) { + msg.data.status = 408; + msg.data.statusText = ""; + msg.data.responseHeaders = ""; + } + } + } +} + +export class GMXhrXhrStrategy implements GMXhrStrategy { + constructor(protected resultParam: SWRequestResultParams) {} + + async fixMsg(msg: TMessageCommAction) { + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && this.resultParam.statusCode > 0 && this.resultParam.statusCode !== msg.data?.status) { + this.resultParam.resultParamStatusCode = this.resultParam.statusCode; + } + } +} diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index d2bfffe55..a6626ca0d 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from "uuid"; import Queue from "@App/pkg/utils/queue"; import { type TDeleteScript } from "../queue"; import { openInCurrentTab } from "@App/pkg/utils/utils"; +import type GMApi from "./gm_api/gm_api"; export interface ConfirmParam { // 权限名 @@ -34,7 +35,11 @@ export interface UserConfirm { type: number; // 1: 允许一次 2: 临时允许全部 3: 临时允许此 4: 永久允许全部 5: 永久允许此 } -export type ApiParamConfirmFn = (request: GMApiRequest, sender: IGetSender) => Promise; +export type ApiParamConfirmFn = ( + request: GMApiRequest, + sender: IGetSender, + GMApiInstance: GMApi +) => Promise; export interface ApiParam { // 默认提供的函数 @@ -112,7 +117,7 @@ export default class PermissionVerify { } // 验证是否有权限 - async verify(request: GMApiRequest, api: ApiValue, sender: IGetSender): Promise { + async verify(request: GMApiRequest, api: ApiValue, sender: IGetSender, GMApiInstance: GMApi): Promise { const { alias, link, confirm } = api.param; if (api.param.default) { return true; @@ -133,11 +138,10 @@ export default class PermissionVerify { (link && link.includes(grantName)) ) { // 需要用户确认 - let result = true; if (confirm) { - result = await this.pushConfirmQueue(request, confirm, sender); + return await this.pushConfirmQueue(request, confirm, sender, GMApiInstance); } - return result; + return true; } } throw new Error("permission not requested"); @@ -163,9 +167,10 @@ export default class PermissionVerify { async pushConfirmQueue( request: GMApiRequest, confirmFn: ApiParamConfirmFn, - sender: IGetSender + sender: IGetSender, + GMApiInstance: GMApi ): Promise { - const confirm = await confirmFn(request, sender); + const confirm = await confirmFn(request, sender, GMApiInstance); if (confirm === true) { return true; } diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 9e06eb89b..ea026ff4a 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -12,6 +12,7 @@ import { type TDeleteScript } from "../queue"; import { calculateHashFromArrayBuffer } from "@App/pkg/utils/crypto"; import { isBase64, parseUrlSRI } from "./utils"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { blobToUint8Array } from "@App/pkg/utils/datatype"; export class ResourceService { logger: Logger; @@ -264,7 +265,7 @@ export class ResourceService { const data = await resp.blob(); const [hash, arrayBuffer, base64] = await Promise.all([ this.calculateHash(data), - data.arrayBuffer(), + blobToUint8Array(data), blobToBase64(data), ]); const resource: Resource = { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 0129dd99b..1a622e1e8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -5,7 +5,7 @@ import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; import type { Script, ScriptDAO, ScriptRunResource, ScriptSite } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; import { type ValueService } from "./value"; -import GMApi, { GMExternalDependencies } from "./gm_api"; +import GMApi, { GMExternalDependencies } from "./gm_api/gm_api"; import type { TDeleteScript, TEnableScript, TInstallScript, TScriptValueUpdate, TSortedScript } from "../queue"; import { type ScriptService } from "./script"; import { runScript, stopScript } from "../offscreen/client"; diff --git a/src/app/service/service_worker/script_update_check.ts b/src/app/service/service_worker/script_update_check.ts index a6b6767de..ae68fb0c4 100644 --- a/src/app/service/service_worker/script_update_check.ts +++ b/src/app/service/service_worker/script_update_check.ts @@ -88,7 +88,6 @@ class ScriptUpdateCheck { }), } : {}; - // console.log(3881, record); this.updateDeliveryTexts(recordLite); } public makeDeliveryPacket(i: number) { diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index efa6d1068..ecf615049 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -61,6 +61,7 @@ export type MessageRequest = { export type GMApiRequest = MessageRequest & { script: Script; + extraCode?: number; // 用于 confirm 传额外资讯 }; export type NotificationMessageOption = { @@ -97,7 +98,7 @@ export type SWScriptMenuItemOption = { accessKey?: string; // 菜单快捷键 autoClose?: boolean; // 默认为 true,false 时点击后不关闭弹出菜单页面 nested?: boolean; // SC特有配置,默认为 true,false 的话浏览器右键菜单项目由三级菜单升至二级菜单 - mIndividualKey?: number; // 内部用。用於单独项提供稳定 GroupKey,当多iframe时,相同的菜单项不自动合并 + mIndividualKey?: number; // 内部用。用于单独项提供稳定 GroupKey,当多iframe时,相同的菜单项不自动合并 mSeparator?: boolean; // 内部用。true 为分隔线 /** 可选输入框类型 */ inputType?: "text" | "number" | "boolean"; diff --git a/src/locales/locales.ts b/src/locales/locales.ts index 761a6447d..8ec2afeb8 100644 --- a/src/locales/locales.ts +++ b/src/locales/locales.ts @@ -65,18 +65,18 @@ export function initLocales(systemConfig: SystemConfig) { } export function i18nName(script: { name: string; metadata: SCMetadata }) { - const m = script.metadata[`name:${i18n.language.toLowerCase()}`]; + const m = script.metadata[`name:${i18n?.language?.toLowerCase()}`]; return m ? m[0] : script.name; } export function i18nDescription(script: { metadata: SCMetadata }) { - const m = script.metadata[`description:${i18n.language.toLowerCase()}`]; + const m = script.metadata[`description:${i18n?.language?.toLowerCase()}`]; return m ? m[0] : script.metadata.description; } // 判断是否是中文用户 export function isChineseUser() { - const language = i18n.language.toLowerCase(); + const language = i18n?.language?.toLowerCase(); return language.startsWith("zh-"); } diff --git a/src/pkg/utils/datatype.ts b/src/pkg/utils/datatype.ts new file mode 100644 index 000000000..e713e27f4 --- /dev/null +++ b/src/pkg/utils/datatype.ts @@ -0,0 +1,88 @@ +/* ---------- Helper functions ---------- */ + +/** Convert a Blob/File to Uint8Array */ +export const blobToUint8Array = async (blob: Blob): Promise> => { + if (typeof blob?.arrayBuffer === "function") return new Uint8Array(await blob.arrayBuffer()); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }; + reader.onerror = reject; + reader.readAsArrayBuffer(blob); + }); +}; + +/** Base64 -> Uint8Array (browser-safe) */ +export function base64ToUint8(b64: string): Uint8Array { + if (typeof (Uint8Array as any).fromBase64 === "function") { + // JS 2025 + return (Uint8Array as any).fromBase64(b64) as Uint8Array; + } else if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { + // Node.js + return Uint8Array.from(Buffer.from(b64, "base64")); + } else { + // Fallback + const bin = atob(b64); + const ab = new ArrayBuffer(bin.length); + const out = new Uint8Array(ab); // <- Uint8Array + for (let i = 0, l = bin.length; i < l; i++) out[i] = bin.charCodeAt(i); + return out; + } +} + +export function uint8ToBase64(uint8arr: Uint8Array): string { + if (typeof (uint8arr as any).toBase64 === "function") { + // JS 2025 + return (uint8arr as any).toBase64() as string; + } else if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { + // Node.js + return Buffer.from(uint8arr).toString("base64") as string; + } else { + // Fallback + let binary = ""; + let i = 0; + while (uint8arr.length - i > 65535) { + binary += String.fromCharCode(...uint8arr.slice(i, i + 65535)); + i += 65535; + } + binary += String.fromCharCode(...(i ? uint8arr.slice(i) : uint8arr)); + return btoa(binary) as string; + } +} + +// Split Uint8Array (or ArrayBuffer) into 2MB chunks as Uint8Array views +export function chunkUint8(src: Uint8Array | ArrayBuffer, chunkSize = 2 * 1024 * 1024): Uint8Array[] { + if (chunkSize <= 0) throw new RangeError("chunkSize must be > 0"); + // Fast path: normalize to a Uint8Array view without copying + const u8 = src instanceof Uint8Array ? src : new Uint8Array(src); + const len = u8.length; + if (len < chunkSize) return len ? [u8.subarray(0)] : []; + const full = Math.floor(len / chunkSize); + const rem = len - full * chunkSize; + const outLen = rem ? full + 1 : full; + const chunks = new Array(outLen); + let offset = 0; + for (let k = 0; k < full; k++) chunks[k] = u8.subarray(offset, (offset += chunkSize)); + if (rem) chunks[full] = u8.subarray(offset); + return chunks; // array of Uint8Array views +} + +// Helper to join Uint8Array chunks +export function concatUint8(chunks: readonly Uint8Array[]): Uint8Array { + const n = chunks.length; + if (n === 0) return new Uint8Array(0); + if (n === 1) { + return new Uint8Array(chunks[0]); + } + let total = 0; + for (let i = 0; i < n; i++) total += chunks[i].byteLength; + const out = new Uint8Array(total); + let offset = 0; + for (let i = 0; i < n; i++) { + const chunk = chunks[i]; + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} diff --git a/src/pkg/utils/uuid.ts b/src/pkg/utils/uuid.ts new file mode 100644 index 000000000..d86b32696 --- /dev/null +++ b/src/pkg/utils/uuid.ts @@ -0,0 +1,3 @@ +import { v4, v5 } from "uuid"; +export const uuidv4 = typeof crypto.randomUUID === "function" ? () => crypto.randomUUID() : v4; +export const uuidv5 = v5; diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts new file mode 100644 index 000000000..d7a2b8b00 --- /dev/null +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -0,0 +1,471 @@ +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"; + +export type RequestResultParams = { + statusCode: number; + responseHeaders: string; + finalUrl: string; +}; + +/** + * ## GM_xmlhttpRequest(details) + * + * The `GM_xmlhttpRequest` function allows userscripts to send HTTP requests and handle responses. + * It accepts a single parameter — an object that defines the request details and callback functions. + * + * --- + * ### Parameters + * + * **`details`** — An object describing the HTTP request options: + * + * | Property | Type | Description | + * |-----------|------|-------------| + * | `method` | `string` | HTTP method (e.g. `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"HEAD"`). | + * | `url` | `string \| URL \| File \| Blob` | Target URL or file/blob to send. | + * | `headers` | `Record` | Optional headers (e.g. `User-Agent`, `Referer`). Some headers may be restricted on Safari/Android. | + * | `data` | `string \| Blob \| File \| Object \| Array \| FormData \| URLSearchParams` | Data to send with POST/PUT requests. | + * | `redirect` | `"follow" \| "error" \| "manual"` | How redirects are handled. | + * | `cookie` | `string` | Additional cookie to include with the request. | + * | `cookiePartition` | `object` | (TM5.2+) Cookie partition key. | + * | `cookiePartition.topLevelSite` | `string` | (TM5.2+) Top frame site for partitioned cookies. | + * | `binary` | `boolean` | Sends data in binary mode. | + * | `nocache` | `boolean` | Prevents caching of the resource. | + * | `revalidate` | `boolean` | Forces cache revalidation. | + * | `timeout` | `number` | Timeout in milliseconds. | + * | `context` | `any` | Custom value added to the response object. | + * | `responseType` | `"arraybuffer" \| "blob" \| "json" \| "stream"` | Type of response data. | + * | `overrideMimeType` | `string` | MIME type override. | + * | `anonymous` | `boolean` | If true, cookies are not sent with the request. | + * | `fetch` | `boolean` | Uses `fetch()` instead of `XMLHttpRequest`. Note: disables `timeout` and progress callbacks in Chrome. | + * | `user` | `string` | Username for authentication. | + * | `password` | `string` | Password for authentication. | + * + * --- + * ### Callback Functions + * + * | Callback | Description | + * |-----------|-------------| + * | `onabort(response)` | Called if the request is aborted. | + * | `onerror(response)` | Called if the request encounters an error. | + * | `onloadstart(response)` | Called when the request starts. Provides access to the stream if `responseType` is `"stream"`. | + * | `onprogress(response)` | Called periodically while the request is loading. | + * | `onreadystatechange(response)` | Called when the request’s `readyState` changes. | + * | `ontimeout(response)` | Called if the request times out. | + * | `onload(response)` | Called when the request successfully completes. | + * + * --- + * ### Response Object + * + * Each callback receives a `response` object with the following properties: + * + * | Property | Type | Description | + * |-----------|------|-------------| + * | `finalUrl` | `string` | The final URL after all redirects. | + * | `readyState` | `number` | The current `readyState` of the request. | + * | `status` | `number` | The HTTP status code. | + * | `statusText` | `string` | The HTTP status text. | + * | `responseHeaders` | `string` | The raw response headers. | + * | `response` | `any` | Parsed response data (depends on `responseType`). | + * | `responseXML` | `Document` | Response data as XML (if applicable). | + * | `responseText` | `string` | Response data as plain text. | + * + * --- + * ### Return Value + * + * `GM_xmlhttpRequest` returns an object with: + * - `abort()` — Function to cancel the request. + * + * The promise-based equivalent is `GM.xmlHttpRequest` (note the capital **H**). + * It resolves with the same `response` object and also provides an `abort()` method. + * + * --- + * ### Example Usage + * + * **Callback-based:** + * ```ts + * GM_xmlhttpRequest({ + * method: "GET", + * url: "https://example.com/", + * headers: { "Content-Type": "application/json" }, + * onload: (response) => { + * console.log(response.responseText); + * }, + * }); + * ``` + * + * **Promise-based:** + * ```ts + * const response = await GM.xmlHttpRequest({ url: "https://example.com/" }) + * .catch(err => console.error(err)); + * + * console.log(response.responseText); + * ``` + * + * --- + * **Note:** + * - The `synchronous` flag in `details` is **not supported**. + * - You must declare appropriate `@connect` permissions in your userscript header. + */ + +// 后台处理端 GM Xhr 实现 +export class BgGMXhr { + private taskId: string; + + private isConnDisconnected: boolean = false; + + constructor( + private details: GMSend.XHRDetails, + private resultParams: RequestResultParams, + private msgConn: MessageConnect, + private strategy?: GMXhrStrategy + ) { + this.taskId = `${Date.now}:${Math.random()}`; + this.isConnDisconnected = false; + } + + onDataReceived(param: { chunk: boolean; type: string; data: any }) { + stackAsyncTask(this.taskId, async () => { + if (this.isConnDisconnected) return; + try { + let buf: Uint8Array | undefined; + // text / stream (uint8array) / buffer (uint8array) / arraybuffer + if (param.data instanceof Uint8Array) { + buf = param.data; + } else if (param.data instanceof ArrayBuffer) { + buf = new Uint8Array(param.data); + } + + if (buf instanceof Uint8Array) { + const d = buf as Uint8Array; + const chunks = chunkUint8(d); + if (!param.chunk) { + const msg: TMessageCommAction = { + action: `reset_chunk_${param.type}`, + data: {}, + }; + this.msgConn.sendMessage(msg); + } + for (const chunk of chunks) { + const msg: TMessageCommAction = { + action: `append_chunk_${param.type}`, + data: { + chunk: uint8ToBase64(chunk), + }, + }; + this.msgConn.sendMessage(msg); + } + } else if (typeof param.data === "string") { + const d = param.data as string; + const c = 2 * 1024 * 1024; + if (!param.chunk) { + const msg: TMessageCommAction = { + action: `reset_chunk_${param.type}`, + data: {}, + }; + this.msgConn.sendMessage(msg); + } + for (let i = 0, l = d.length; i < l; i += c) { + const chunk = d.substring(i, i + c); + if (chunk.length) { + const msg: TMessageCommAction = { + action: `append_chunk_${param.type}`, + data: { + chunk: chunk, + }, + }; + this.msgConn.sendMessage(msg); + } + } + } + } catch (e: any) { + console.error(e); + } + }); + } + + callback( + result: Record & { + // + readyState: GMTypes.ReadyState; + status: number; + statusText: string; + responseHeaders: string | null; + // + useFetch: boolean; + eventType: string; + ok: boolean; + contentType: string; + error: string | Error | undefined; + } + ) { + const data = { + ...result, + finalUrl: this.resultParams.finalUrl, + responseHeaders: this.resultParams.responseHeaders || result.responseHeaders || "", + }; + const eventType = result.eventType; + const msg: TMessageCommAction = { + action: `on${eventType}`, + data: data, + }; + stackAsyncTask(this.taskId, async () => { + await this.strategy?.fixMsg(msg); + if (eventType === "loadend") { + this.onloaded?.(); + } + if (this.isConnDisconnected) return; + this.msgConn.sendMessage(msg); + }); + } + + private onloaded: (() => void) | undefined; + + onLoaded(fn: () => void) { + this.onloaded = fn; + } + + abort: (() => void) | undefined; + + async bgXhrRequestFn() { + const details = this.details; + + details.data = dataDecode(details.data as any); + if (details.data === undefined) delete details.data; + + const anonymous = details.anonymous ?? details.mozAnon ?? false; + + const redirect = details.redirect; + + const isFetch = details.fetch ?? false; + + const isBufferStream = details.responseType === "stream"; + + let xhrResponseType: "arraybuffer" | "text" | "" = ""; + + const useFetch = isFetch || !!redirect || anonymous || isBufferStream; + + const isNoCache = !!details.nocache; + + const prepareXHR = async () => { + let rawData = (details.data = await details.data); + + const baseXHR = useFetch + ? new FetchXHR(isBufferStream, this.onDataReceived.bind(this), (opts: RequestInit) => { + if (redirect) { + opts.redirect = redirect; + } + if (anonymous) { + opts.credentials = "omit"; // ensures no cookies or auth headers are sent + // opts.referrerPolicy = "no-referrer"; // https://javascript.info/fetch-api + } + // details for nocache and revalidate shall refer to the following issue: + // https://github.com/Tampermonkey/tampermonkey/issues/962 + if (isNoCache) { + // 除了传统的 "Cache-Control", 在浏览器fetch API层面也做一做处理 + opts.cache = "no-store"; + } + }) + : new XMLHttpRequest(); + + this.abort = () => { + baseXHR.abort(); + }; + + const url = details.url; + if (details.overrideMimeType) { + baseXHR.overrideMimeType(details.overrideMimeType); + } + + let contentType = ""; + let responseHeaders: string | null = null; + let finalStateChangeEvent: Event | ProgressEvent | null = null; + let canTriggerFinalStateChangeEvent = false; + const callback = (evt: Event | ProgressEvent, err?: Error | string) => { + const xhr = baseXHR; + const eventType = evt.type; + + if (eventType === "load") { + canTriggerFinalStateChangeEvent = true; + if (finalStateChangeEvent) callback(finalStateChangeEvent); + } else if (eventType === "readystatechange" && xhr.readyState === 4) { + // readyState4 的readystatechange或会重复,见 https://github.com/violentmonkey/violentmonkey/issues/1862 + if (!canTriggerFinalStateChangeEvent) { + finalStateChangeEvent = evt; + return; + } + } + canTriggerFinalStateChangeEvent = false; + finalStateChangeEvent = null; + + // contentType 和 responseHeaders 只读一次 + contentType = contentType || xhr.getResponseHeader("Content-Type") || ""; + if (contentType && !responseHeaders) { + responseHeaders = xhr.getAllResponseHeaders(); + } + if (!(xhr instanceof FetchXHR)) { + const response = xhr.response; + if (xhr.readyState === 4 && eventType === "readystatechange") { + if (xhrResponseType === "" || xhrResponseType === "text") { + this.onDataReceived({ chunk: false, type: "text", data: xhr.responseText }); + } else if (xhrResponseType === "arraybuffer" && response instanceof ArrayBuffer) { + this.onDataReceived({ chunk: false, type: "arraybuffer", data: response }); + } + } + } + this.callback({ + /* + + + finalUrl: string; // sw handle + readyState: 0 | 4 | 2 | 3 | 1; + status: number; + statusText: string; + responseHeaders: string; + error?: string; // sw handle? + + useFetch: boolean, + eventType: string, + ok: boolean, + contentType: string, + error: undefined | string, + + */ + + useFetch: useFetch, + eventType: eventType, + ok: xhr.status >= 200 && xhr.status < 300, + contentType, + // Always + readyState: xhr.readyState as GMTypes.ReadyState, + // After response headers + status: xhr.status, + statusText: xhr.statusText, + // After load + // response: response, + // responseText: responseText, + // responseXML: responseXML, + // After headers received + responseHeaders: responseHeaders, + responseURL: xhr.responseURL, + // How to get the error message in native XHR ? + error: eventType !== "error" ? undefined : (err as Error)?.message || err || "Unknown Error", + }); + + evt.type; + }; + baseXHR.onabort = callback; + baseXHR.onloadstart = callback; + baseXHR.onload = callback; + baseXHR.onerror = callback; + baseXHR.onprogress = callback; + baseXHR.ontimeout = callback; + baseXHR.onreadystatechange = callback; + baseXHR.onloadend = callback; + + baseXHR.open(details.method ?? "GET", url, true, details.user, details.password); + + if (details.responseType === "blob" || details.responseType === "document") { + const err = new Error( + "Invalid Internal Calling. The internal network function shall only do text/arraybuffer/stream" + ); + throw err; + } + // "" | "arraybuffer" | "blob" | "document" | "json" | "text" + if (details.responseType === "json") { + // 故意忽略,json -> text,兼容TM + } else if (details.responseType === "stream") { + xhrResponseType = baseXHR.responseType = "arraybuffer"; + } else if (details.responseType) { + xhrResponseType = baseXHR.responseType = details.responseType; + } + if (details.timeout) baseXHR.timeout = details.timeout; + baseXHR.withCredentials = true; + + // Apply headers + if (details.headers) { + for (const [key, value] of Object.entries(details.headers)) { + baseXHR.setRequestHeader(key, value); + } + } + + // details for nocache and revalidate shall refer to the following issue: + // https://github.com/Tampermonkey/tampermonkey/issues/962 + if (details.nocache) { + // Never cache anything (always fetch new) + // + // Explanation: + // - The browser and proxies are not allowed to store this response anywhere. + // - Useful for sensitive or secure data (like banking info or private dashboards). + // - Ensures no cached version exists on disk, in memory, or in intermediary caches. + // + baseXHR.setRequestHeader("Cache-Control", "no-cache, no-store"); + baseXHR.setRequestHeader("Pragma", "no-cache"); // legacy HTTP/1.0 fallback + baseXHR.setRequestHeader("Expires", "0"); // legacy HTTP/1.0 fallback + } else if (details.revalidate) { + // Cache is allowed but must verify with server + // + // Explanation: + // - The response can be cached locally, but it’s marked as “immediately stale”. + // - On each request, the browser must check with the server (via ETag or Last-Modified) + // to confirm whether it can reuse the cached version. + // - Ideal for data that rarely changes but should always be validated for freshness. + // + baseXHR.setRequestHeader("Cache-Control", "max-age=0, must-revalidate"); + } + + // --- Handle request body --- + // 标准 xhr request 的 body 类型: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/send + const isStandardRequestBody = + rawData instanceof URLSearchParams || + typeof rawData === "string" || + typeof rawData === "number" || + typeof rawData === "boolean" || + rawData === null || + rawData === undefined || + rawData instanceof Blob || + rawData instanceof FormData || + rawData instanceof ArrayBuffer || + rawData instanceof Uint8Array; + // 其他标准以外的物件类型则尝试 JSON 转换 + if (!isStandardRequestBody && typeof rawData === "object") { + if ((baseXHR.getResponseHeader("Content-Type") || "application/json") !== "application/json") { + // JSON body + try { + rawData = JSON.stringify(rawData); + baseXHR.setRequestHeader("Content-Type", "application/json"); + } catch { + rawData = undefined; + } + } else { + rawData = undefined; + } + } + + if (details.binary && typeof rawData === "string") { + // Send the data string as a blob. Compatibility with TM/VM/GM + rawData = new Blob([rawData], { type: "application/octet-stream" }); + } + + // Send data (if any) + baseXHR.send(rawData ?? null); + }; + + await prepareXHR(); + } + + do() { + this.bgXhrRequestFn().catch((e: any) => { + this.abort?.(); + console.error(e); + }); + this.msgConn.onDisconnect(() => { + this.isConnDisconnected = true; + this.abort?.(); + // console.warn("msgConn.onDisconnect"); + }); + } +} diff --git a/src/pkg/utils/xhr/fetch_xhr.ts b/src/pkg/utils/xhr/fetch_xhr.ts new file mode 100644 index 000000000..f98788c49 --- /dev/null +++ b/src/pkg/utils/xhr/fetch_xhr.ts @@ -0,0 +1,398 @@ +interface ProgressLikeEvent { + loaded: number; + total: number; + lengthComputable: boolean; +} + +type ResponseType = "" | "text" | "json" | "blob" | "arraybuffer" | "document"; + +export class FetchXHR { + constructor( + private isBufferStream: boolean, + private onDataReceived: (param: { chunk: boolean; type: string; data: any }) => void, + private extraOptsFn: (opts: RequestInit) => void + ) {} + + // XHR-like constants for convenience + static readonly UNSENT = 0 as const; + static readonly OPENED = 1 as const; + static readonly HEADERS_RECEIVED = 2 as const; + static readonly LOADING = 3 as const; + static readonly DONE = 4 as const; + + // Public XHR-ish fields + readyState: GMTypes.ReadyState = 0; + status = 0; + statusText = ""; + responseURL = ""; + responseType: ResponseType = ""; + response: unknown = null; + responseText = ""; // not used + responseXML = null; // not used + timeout = 0; // ms; 0 = no timeout + withCredentials = false; // fetch doesn’t support cookies toggling per-request; kept for API parity + + // Event handlers + onreadystatechange: ((evt: Partial) => void) | null = null; + onloadstart: ((evt: Partial) => void) | null = null; + onload: ((evt: Partial) => void) | null = null; + onloadend: ((evt: Partial) => void) | null = null; + onerror: ((evt: Partial, err?: Error | string) => void) | null = null; + onprogress: ((evt: Partial & { type: string }) => void) | null = null; + onabort: ((evt: Partial) => void) | null = null; + ontimeout: ((evt: Partial) => void) | null = null; + + private isAborted: boolean = false; + private reqDone: boolean = false; + + // Internal + private method: string | null = null; + private url: string | null = null; + private headers = new Headers(); + private body: BodyInit | null = null; + private controller: AbortController | null = null; + private timedOut = false; + private timeoutId: number | null = null; + private _responseHeaders: { + getAllResponseHeaders: () => string; + getResponseHeader: (name: string) => string | null; + cache: Record; + } | null = null; + + open(method: string, url: string, _async?: boolean, username?: string, password?: string) { + if (username && password !== undefined) { + this.headers.set("Authorization", "Basic " + btoa(`${username}:${password}`)); + } else if (username && password === undefined) { + this.headers.set("Authorization", "Basic " + btoa(`${username}:`)); + } + this.method = method.toUpperCase(); + this.url = url; + this.readyState = FetchXHR.OPENED; + this._emitReadyStateChange(); + } + + setRequestHeader(name: string, value: string) { + this.headers.set(name, value); + } + + getAllResponseHeaders(): string { + if (this._responseHeaders === null) return ""; + return this._responseHeaders.getAllResponseHeaders(); + } + + getResponseHeader(name: string): string | null { + // Per XHR semantics, header names are case-insensitive + if (this._responseHeaders === null) return null; + return this._responseHeaders.getResponseHeader(name); + } + + overrideMimeType(_mime: string) { + // Not supported by fetch; no-op to keep parity. + } + + async send(body?: BodyInit | null) { + if (this.readyState !== FetchXHR.OPENED || !this.method || !this.url) { + throw new Error("Invalid state: call open() first."); + } + this.reqDone = false; + + this.body = body ?? null; + this.controller = new AbortController(); + + // Setup timeout if specified + if (this.timeout > 0) { + this.timeoutId = setTimeout(() => { + if (this.controller && !this.reqDone) { + this.timedOut = true; + this.controller.abort(); + } + }, this.timeout) as unknown as number; + } + + try { + const opts: RequestInit = { + method: this.method, + headers: this.headers, + body: this.body, + signal: this.controller.signal, + // credentials: 'include' cannot be toggled per request like XHR.withCredentials; set at app level if needed. + }; + this.extraOptsFn?.(opts); + this.onloadstart?.({ type: "loadstart" }); + const res = await fetch(this.url, opts); + + // Update status + headers + this.status = res.status; + this.statusText = res.statusText ?? ""; + this.responseURL = res.url ?? this.url; + this._responseHeaders = { + getAllResponseHeaders(): string { + let ret: string | undefined = this.cache[""]; + if (ret === undefined) { + ret = ""; + res.headers.forEach((v, k) => { + ret += `${k}: ${v}\r\n`; + }); + this.cache[""] = ret; + } + return ret; + }, + getResponseHeader(name: string): string | null { + if (!name) return null; + return (this.cache[name] ||= res.headers.get(name)) as string | null; + }, + cache: {}, + }; + + const ct = res.headers.get("content-type")?.toLowerCase() || ""; + const ctI = ct.indexOf("charset="); + let encoding = "utf-8"; // fetch defaults are UTF-8 + if (ctI >= 0) { + let ctJ = ct.indexOf(";", ctI + 8); + ctJ = ctJ > ctI ? ctJ : ct.length; + encoding = ct.substring(ctI + 8, ctJ).trim() || encoding; + } + + this.readyState = FetchXHR.HEADERS_RECEIVED; + this._emitReadyStateChange(); + + let responseOverrided: ReadableStream | null = null; + + // Storage buffers for different responseTypes + // const chunks: Uint8Array[] = []; + + // From Chromium 105, you can start a request before you have the whole body available by using the Streams API. + // https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests?hl=en + // -> TextDecoderStream + + let textDecoderStream; + let textDecoder; + const receiveAsPlainText = + this.responseType === "" || + this.responseType === "text" || + this.responseType === "document" || // SC的处理是把 document 当作 blob 处理。仅保留这处理实现完整工具库功能 + this.responseType === "json"; + + if (receiveAsPlainText) { + if (typeof TextDecoderStream === "function" && Symbol.asyncIterator in ReadableStream.prototype) { + // try ReadableStream + try { + textDecoderStream = new TextDecoderStream(encoding); + } catch { + textDecoderStream = new TextDecoderStream("utf-8"); + } + } else { + // fallback to ReadableStreamDefaultReader + // fatal: true - throw on errors instead of inserting the replacement char + try { + textDecoder = new TextDecoder(encoding, { fatal: true, ignoreBOM: true }); + } catch { + textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + } + } + } + + 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) { + // Stream body for progress + let streamReader; + let streamReadable; + if (textDecoderStream) { + streamReadable = res.body?.pipeThrough(textDecoderStream); + if (!streamReadable) throw new Error("streamReadable is undefined."); + } else { + streamReader = res.body?.getReader(); + if (!streamReader) throw new Error("streamReader is undefined."); + } + + let didLoaded = false; + + const contentLengthHeader = res.headers.get("content-length"); + const total = contentLengthHeader ? Number(contentLengthHeader) : 0; + let loaded = 0; + const firstLoad = () => { + if (!didLoaded) { + didLoaded = true; + // Move to LOADING state as soon as we start reading + this.readyState = FetchXHR.LOADING; + this._emitReadyStateChange(); + } + }; + let streamDecoding = false; + const pushBuffer = (chunk: Uint8Array | string | undefined | null) => { + if (!chunk) return; + const added = typeof chunk === "string" ? chunk.length : chunk.byteLength; + if (added) { + loaded += added; + if (typeof chunk === "string") { + this.onDataReceived({ chunk: true, type: "text", data: chunk }); + } else if (this.isBufferStream) { + this.onDataReceived({ chunk: true, type: "stream", data: chunk }); + } else if (receiveAsPlainText) { + streamDecoding = true; + const data = textDecoder!.decode(chunk, { stream: true }); // keep decoder state between chunks + this.onDataReceived({ chunk: true, type: "text", data: data }); + } else { + this.onDataReceived({ chunk: true, type: "buffer", data: chunk }); + } + + if (this.onprogress) { + this.onprogress({ + type: "progress", + loaded, // decoded buffer bytelength. no specification for decoded or encoded. https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/loaded + total, // Content-Length. The total encoded bytelength (gzip/br) + lengthComputable: false, // always assume compressed data. See https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/lengthComputable + }); + } + } + }; + + if (this.isBufferStream && streamReader) { + const streamReaderConst = streamReader; + let myController = null; + const makeController = async (controller: ReadableStreamDefaultController) => { + try { + while (true) { + const { done, value } = await streamReaderConst.read(); + firstLoad(); + if (done) break; + controller.enqueue(new Uint8Array(value)); + pushBuffer(value); + } + controller.close(); + } catch { + controller.error("XHR failed"); + } + }; + responseOverrided = new ReadableStream({ + start(controller) { + myController = controller; + }, + }); + this.response = responseOverrided; + await makeController(myController!); + } else if (streamReadable) { + // receiveAsPlainText + if (Symbol.asyncIterator in streamReadable && typeof streamReadable[Symbol.asyncIterator] === "function") { + // https://developer.mozilla.org/ja/docs/Web/API/ReadableStream + //@ts-ignore + for await (const chunk of streamReadable) { + firstLoad(); // ensure firstLoad() is always called + if (chunk.length) { + pushBuffer(chunk); + } + } + } else { + const streamReader = streamReadable.getReader(); + try { + while (true) { + const { done, value } = await streamReader.read(); + firstLoad(); // ensure firstLoad() is always called + if (done) break; + pushBuffer(value); + } + } finally { + streamReader.releaseLock(); + } + } + } else if (streamReader) { + try { + while (true) { + const { done, value } = await streamReader.read(); + firstLoad(); // ensure firstLoad() is always called + if (done) { + if (streamDecoding) { + const data = textDecoder!.decode(); // flush trailing bytes + // this.onDataReceived({ chunk: true, type: "text", data: data }); + pushBuffer(data); + } + break; + } + pushBuffer(value); + } + } finally { + streamReader.releaseLock(); + } + } else { + firstLoad(); + // Fallback: no streaming support — read fully + const buf = new Uint8Array(await res.arrayBuffer()); + pushBuffer(buf); + if (streamDecoding) { + const data = textDecoder!.decode(); // flush trailing bytes + // this.onDataReceived({ chunk: true, type: "text", data: data }); + pushBuffer(data); + } + } + } + + this.status = customStatus || res.status; + this.statusText = res.statusText ?? ""; + this.responseURL = res.url ?? this.url; + + if (this.isAborted) { + const err = new Error("AbortError"); + err.name = "AbortError"; + throw err; + } + + this.readyState = FetchXHR.DONE; + this._emitReadyStateChange(); + this.onload?.({ type: "load" }); + } catch (err) { + this.controller = null; + if (this.timeoutId != null) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + this.status = 0; + + if (this.timedOut && !this.reqDone) { + this.reqDone = true; + this.ontimeout?.({ type: "timeout" }); + return; + } + + if ((err as any)?.name === "AbortError" && !this.reqDone) { + this.reqDone = true; + this.readyState = FetchXHR.UNSENT; + this.status = 0; + this.statusText = ""; + this.onabort?.({ type: "abort" }); + return; + } + + this.readyState = FetchXHR.DONE; + if (!this.reqDone) { + this.reqDone = true; + this.onerror?.({ type: "error" }, (err || "Unknown Error") as Error | string); + } + } finally { + this.controller = null; + if (this.timeoutId != null) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + this.reqDone = true; + this.onloadend?.({ type: "loadend" }); + } + } + + abort() { + this.isAborted = true; + if (!this.reqDone) { + this.controller?.abort(); + } + } + + // Utility to fire readyState changes + private _emitReadyStateChange() { + this.onreadystatechange?.({ type: "readystatechange" }); + } +} diff --git a/src/pkg/utils/xhr/xhr_data.ts b/src/pkg/utils/xhr/xhr_data.ts new file mode 100644 index 000000000..405c2b59f --- /dev/null +++ b/src/pkg/utils/xhr/xhr_data.ts @@ -0,0 +1,231 @@ +import { base64ToUint8, uint8ToBase64 } from "../datatype"; + +export const typedArrayTypes = [ + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array, +]; + +export const typedArrayTypesText = typedArrayTypes.map((e) => e.name); + +// 由于Decode端总是service_worker/offscreen +// 假如当前Encode端环境没有 URL.createObjectURL, 必定是 service_worker (page/content/offscreen 都有 URL.createObjectURL) +// Encode端环境没有 URL.createObjectURL -> OPFS -> service_worker/offscreen 读取 OPFS +// Encode端环境有 URL.createObjectURL -> URL.createObjectURL -> service_worker/offscreen 读取 BlobURL +const innerToBlobUrl = + typeof URL.createObjectURL === "function" + ? (blob: Blob): string => { + // 执行端:content/page/offscreen/extension page + return URL.createObjectURL(blob); // 多于36字元;浏览器重启会清掉 + } + : async (_blob: Blob): Promise => { + // 执行端:service_worker + throw "Invalid Call of innerToBlobUrl"; // 背景腳本在 offscreen 執行 + // const filename = await setOPFSTemp(blob); // SW重启会清掉 + // return filename; // OPFS. 只传回36字元的uuid + }; +const innerFromBlobUrl = async (f: string): Promise => { + // 执行端:service_worker/offscreen + if (f.length === 36) { + throw "Invalid Call of innerFromBlobUrl"; // 背景腳本在 offscreen 執行 + // OPFS + // const file = await getOPFSTemp(f); + // if (!file) throw new Error("OPFS Temp File is missing"); + // const blob = new Blob([file], { type: file.type }); // pure blob, zero-copy + // return blob; + } else { + const res = await fetch(f); + const blob = await res.blob(); + return blob; + } +}; + +export const dataDecode = (pData: any) => { + let kData = undefined; + if (!pData || !pData.type) { + kData = undefined; + } else { + if (pData.type === "null") { + kData = null; + } else if (pData.type === "undefined") { + kData = undefined; + } else if (pData.type === "object") { + kData = JSON.parse(pData.m); + } else if (pData.type === "DataView") { + const ubuf = base64ToUint8(pData.m); + kData = new DataView(ubuf.buffer); + } else if (pData.type === "ArrayBuffer") { + const ubuf = base64ToUint8(pData.m); + kData = ubuf.buffer; + } else if (pData.type === "Blob") { + const [blobUrl] = pData.m; + kData = Promise.resolve(innerFromBlobUrl(blobUrl)); + } else if (pData.type === "File") { + const [blobUrl, fileName, lastModified] = pData.m; + kData = Promise.resolve(innerFromBlobUrl(blobUrl)).then((blob) => { + if (blob instanceof File) return blob; + const type = blob.type || "application/octet-stream"; + return new File([blob], fileName, { type, lastModified }); + }); + } else if (pData.type === "FormData") { + const d = pData.m as GMSend.XHRFormData[]; + const fd = new FormData(); + kData = Promise.all( + d.map(async (o) => { + if (o.type === "text") fd.append(o.key, o.val); + else if (o.type === "file") { + const blob = await innerFromBlobUrl(o.val); + let ret; + if (o.filename) { + const type = o.mimeType || blob.type || "application/octet-stream"; + const filename = typeof o.filename === "string" ? o.filename : "blob"; + const lastModified = o.lastModified; + ret = new File([blob], filename, { type, lastModified }); + fd.append(o.key, ret, filename); + } else { + ret = blob; + // We don't have a preserved filename; browsers will use "blob" by default. + fd.append(o.key, ret); + } + } + }) + ).then(() => fd); + } else if (pData.type === "URLSearchParams") { + kData = new URLSearchParams(`${pData.m}`); + } else { + const idx = typedArrayTypesText.indexOf(pData.type); + if (idx >= 0) { + const ubuf = base64ToUint8(pData.m); + const T = typedArrayTypes[idx]; + kData = ubuf instanceof T ? ubuf : new T(ubuf.buffer); + } else { + kData = pData.m; + } + } + } + return kData; +}; + +export const dataEncode = async (kData: any) => { + if (kData?.then) { + kData = await kData; + } + if (kData instanceof Document) { + throw new Error("GM xhr data does not support Document"); + } + // 处理数据 + let extData = { + type: kData === undefined ? "undefined" : kData === null ? "null" : "undefined", + m: null, + } as { + type: string; + m: any; + }; + if (kData instanceof ReadableStream) { + kData = await new Response(kData).blob(); + } + if (kData instanceof DataView) { + const uint8Copy = new Uint8Array(kData.buffer, kData.byteOffset, kData.byteLength); + extData = { + type: "DataView", + m: uint8ToBase64(uint8Copy), + }; + } else if (kData instanceof URLSearchParams) { + // `${new URLSearchParams('你=好')}` -> '%E4%BD%A0=%E5%A5%BD' + // new URLSearchParams('%E4%BD%A0=%E5%A5%BD').get('你') -> '好' + extData = { + type: "URLSearchParams", + m: `${kData}`, // application/x-www-form-urlencoded percent-encoded + }; + } else if (kData instanceof FormData) { + // 处理FormData + // param.dataType = "FormData"; + // 处理FormData中的数据 + const data = (await Promise.all( + [...kData.entries()].map(([key, val]) => + val instanceof File + ? Promise.resolve(innerToBlobUrl(val)).then( + (url) => + ({ + key, + type: "file", + val: url, + mimeType: val.type, + filename: val.name, + lastModified: val.lastModified, + }) as GMSend.XHRFormDataFile + ) + : ({ + key, + type: "text", + val, + } as GMSend.XHRFormDataText) + ) + )) as GMSend.XHRFormData[]; + // param.data = data; + extData = { + type: "FormData", + m: data, + }; + } else if (ArrayBuffer.isView(kData)) { + if (kData instanceof Uint8Array) { + extData = { + type: "Uint8Array", + m: uint8ToBase64(kData), + }; + } else { + const idx = typedArrayTypes.findIndex((e) => kData instanceof e); + if (idx >= 0) { + const buf = kData.buffer; + extData = { + type: typedArrayTypesText[idx], + m: uint8ToBase64(new Uint8Array(buf)), + }; + } else { + throw new Error("Unsupported ArrayBuffer View"); + } + } + } else if (kData instanceof Blob) { + if (kData instanceof File) { + extData = { + type: "File", + m: [await innerToBlobUrl(kData), kData?.name, kData?.lastModified], + }; + } else { + extData = { + type: "Blob", + m: [await innerToBlobUrl(kData)], + }; + } + } else if (kData instanceof ArrayBuffer) { + extData = { + type: "ArrayBuffer", + m: uint8ToBase64(new Uint8Array(kData)), + }; + } else if (kData && typeof kData === "object") { + let str; + try { + str = JSON.stringify(kData); + } catch (_e: any) { + str = Array.isArray(kData) ? "[]" : "{}"; + } + extData = { + type: "object", + m: str, + }; + } else if (kData !== null && kData !== undefined) { + extData = { + type: typeof kData, + m: kData, + }; + } + return extData; +}; diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index 71557a284..8aa55a2df 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -168,7 +168,7 @@ declare function GM_openInTab(url: string): GMTypes.Tab | undefined; declare function GM_xmlhttpRequest(details: GMTypes.XHRDetails): GMTypes.AbortHandle; -declare function GM_download(details: GMTypes.DownloadDetails): GMTypes.AbortHandle; +declare function GM_download(details: GMTypes.DownloadDetails): GMTypes.AbortHandle; declare function GM_download(url: string, filename: string): GMTypes.AbortHandle; declare function GM_getTab(callback: (obj: object) => void): void; @@ -472,10 +472,10 @@ declare namespace GMTypes { responseHeaders?: string; status?: number; statusText?: string; - response?: string | Blob | ArrayBuffer | Document | ReadableStream | null; + response?: string | Blob | ArrayBuffer | Document | ReadableStream | null; responseText?: string; responseXML?: Document | null; - responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; + responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | ""; } interface XHRProgress extends XHRResponse { @@ -490,11 +490,13 @@ declare namespace GMTypes { type Listener = (event: OBJ) => unknown; type ContextType = unknown; + type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + interface XHRDetails { method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; - url: string; + url: string | URL | File | Blob; headers?: { [key: string]: string }; - data?: string | FormData | Blob; + data?: GMXHRDataType; cookie?: string; binary?: boolean; timeout?: number; @@ -502,19 +504,25 @@ declare namespace GMTypes { responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; // stream 在当前版本是一个较为简陋的实现 overrideMimeType?: string; anonymous?: boolean; + mozAnon?: boolean; // 发送请求时不携带cookie (兼容Greasemonkey) fetch?: boolean; user?: string; password?: string; nocache?: boolean; + revalidate?: boolean; // 强制重新验证缓存内容:允许缓存,但必须在使用缓存内容之前重新验证 redirect?: "follow" | "error" | "manual"; // 为了与tm保持一致, 在v0.17.0后废弃maxRedirects, 使用redirect替代, 会强制使用fetch模式 + cookiePartition?: Record & { + topLevelSite?: string; // 表示分区 cookie 的顶部帧站点 + }; // 包含用于发送和接收的分区 cookie 的分区键 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + context?: any; // 自定义值,传递给响应的 response.context 属性 onload?: Listener; onloadstart?: Listener; onloadend?: Listener; onprogress?: Listener; onreadystatechange?: Listener; - ontimeout?: () => void; - onabort?: () => void; + ontimeout?: Listener; + onabort?: Listener; onerror?: (err: string | (XHRResponse & { error: string })) => void; } @@ -527,21 +535,30 @@ declare namespace GMTypes { details?: string; } - interface DownloadDetails { - method?: "GET" | "POST"; - downloadMode?: "native" | "browser"; - url: string; + interface DownloadDetails { + // TM/SC 标准参数 + url: URL; name: string; headers?: { [key: string]: string }; saveAs?: boolean; - timeout?: number; - cookie?: string; - anonymous?: boolean; + conflictAction?: "uniquify" | "overwrite" | "prompt"; - onerror?: Listener; - ontimeout?: () => void; + // 其他参数 + timeout?: number; // SC/VM + anonymous?: boolean; // SC/VM + context?: ContextType; // SC/VM + user?: string; // SC/VM + password?: string; // SC/VM + + method?: "GET" | "POST"; // SC + downloadMode?: "native" | "browser"; // SC + cookie?: string; // SC + + // TM/SC 标准回调 onload?: Listener; + onerror?: Listener; onprogress?: Listener; + ontimeout?: (arg1?: any) => void; } interface NotificationThis extends NotificationDetails { diff --git a/src/types/main.d.ts b/src/types/main.d.ts index ea7685986..6d1c6760e 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -48,7 +48,7 @@ declare namespace GMSend { method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; url: string; headers?: { [key: string]: string }; - data?: string | Array; + data?: string | Array | any; cookie?: string; /** * @@ -60,23 +60,38 @@ declare namespace GMSend { binary?: boolean; timeout?: number; context?: CONTEXT_TYPE; - responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; + responseType?: "" | "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; overrideMimeType?: string; anonymous?: boolean; + /** Send request without cookies (Greasemonkey) */ + mozAnon?: boolean; fetch?: boolean; user?: string; password?: string; nocache?: boolean; + /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ + revalidate?: boolean; dataType?: "FormData" | "Blob"; redirect?: "follow" | "error" | "manual"; + byPassConnect?: boolean; } - interface XHRFormData { - type?: "file" | "text"; + interface XHRFormDataFile { + type: "file"; key: string; val: string; - filename?: string; + mimeType: string; + filename: string; + lastModified: number; } + + interface XHRFormDataText { + type: "text"; + key: string; + val: string; + } + + type XHRFormData = XHRFormDataFile | XHRFormDataText; } declare namespace globalThis { diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 71557a284..35c2f0f99 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -168,7 +168,7 @@ declare function GM_openInTab(url: string): GMTypes.Tab | undefined; declare function GM_xmlhttpRequest(details: GMTypes.XHRDetails): GMTypes.AbortHandle; -declare function GM_download(details: GMTypes.DownloadDetails): GMTypes.AbortHandle; +declare function GM_download(details: GMTypes.DownloadDetails): GMTypes.AbortHandle; declare function GM_download(url: string, filename: string): GMTypes.AbortHandle; declare function GM_getTab(callback: (obj: object) => void): void; @@ -466,16 +466,23 @@ declare namespace GMTypes { type SWOpenTabOptions = OpenTabOptions & Required>; + type ReadyState = + | 0 // UNSENT + | 1 // OPENED + | 2 // HEADERS_RECEIVED + | 3 // LOADING + | 4; // DONE + interface XHRResponse { finalUrl?: string; - readyState?: 0 | 1 | 2 | 3 | 4; + readyState?: ReadyState; responseHeaders?: string; status?: number; statusText?: string; - response?: string | Blob | ArrayBuffer | Document | ReadableStream | null; + response?: string | Blob | ArrayBuffer | Document | ReadableStream | null; responseText?: string; responseXML?: Document | null; - responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; + responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream" | ""; } interface XHRProgress extends XHRResponse { @@ -490,11 +497,13 @@ declare namespace GMTypes { type Listener = (event: OBJ) => unknown; type ContextType = unknown; + type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + interface XHRDetails { method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; - url: string; + url: string | URL | File | Blob; headers?: { [key: string]: string }; - data?: string | FormData | Blob; + data?: GMXHRDataType; cookie?: string; binary?: boolean; timeout?: number; @@ -502,19 +511,25 @@ declare namespace GMTypes { responseType?: "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; // stream 在当前版本是一个较为简陋的实现 overrideMimeType?: string; anonymous?: boolean; + mozAnon?: boolean; // 发送请求时不携带cookie (兼容Greasemonkey) fetch?: boolean; user?: string; password?: string; nocache?: boolean; + revalidate?: boolean; // 强制重新验证缓存内容:允许缓存,但必须在使用缓存内容之前重新验证 redirect?: "follow" | "error" | "manual"; // 为了与tm保持一致, 在v0.17.0后废弃maxRedirects, 使用redirect替代, 会强制使用fetch模式 + cookiePartition?: Record & { + topLevelSite?: string; // 表示分区 cookie 的顶部帧站点 + }; // 包含用于发送和接收的分区 cookie 的分区键 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + context?: any; // 自定义值,传递给响应的 response.context 属性 onload?: Listener; onloadstart?: Listener; onloadend?: Listener; onprogress?: Listener; onreadystatechange?: Listener; - ontimeout?: () => void; - onabort?: () => void; + ontimeout?: Listener; + onabort?: Listener; onerror?: (err: string | (XHRResponse & { error: string })) => void; } @@ -527,21 +542,30 @@ declare namespace GMTypes { details?: string; } - interface DownloadDetails { - method?: "GET" | "POST"; - downloadMode?: "native" | "browser"; - url: string; + interface DownloadDetails { + // TM/SC 标准参数 + url: URL; name: string; headers?: { [key: string]: string }; saveAs?: boolean; - timeout?: number; - cookie?: string; - anonymous?: boolean; + conflictAction?: "uniquify" | "overwrite" | "prompt"; - onerror?: Listener; - ontimeout?: () => void; + // 其他参数 + timeout?: number; // SC/VM + anonymous?: boolean; // SC/VM + context?: ContextType; // SC/VM + user?: string; // SC/VM + password?: string; // SC/VM + + method?: "GET" | "POST"; // SC + downloadMode?: "native" | "browser"; // SC + cookie?: string; // SC + + // TM/SC 标准回调 onload?: Listener; + onerror?: Listener; onprogress?: Listener; + ontimeout?: (arg1?: any) => void; } interface NotificationThis extends NotificationDetails { diff --git a/tests/mocks/blob.ts b/tests/mocks/blob.ts new file mode 100644 index 000000000..e6b05ff7c --- /dev/null +++ b/tests/mocks/blob.ts @@ -0,0 +1,118 @@ +export const RealBlob = globalThis.Blob; + +class Blob { + constructor(_parts?: BlobPart[], _options?: BlobPropertyBag) {} + get size(): number { + return 0; + } + get type(): string { + return ""; + } + async text(): Promise { + return ""; + } + async arrayBuffer(): Promise { + return new ArrayBuffer(0); + } + slice(): Blob { + return new Blob(); + } + stream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + } +} + +// --- Mock Blob --- +interface BlobPropertyBag { + type?: string; +} + +/** Convert BlobPart[] to a single Uint8Array (UTF-8 for strings). */ +function partsToUint8Array(parts: ReadonlyArray | undefined): Uint8Array { + if (!parts || parts.length === 0) return new Uint8Array(0); + + const enc = new TextEncoder(); + const toU8 = (part: BlobPart): Uint8Array => { + if (part instanceof Uint8Array) return part; + if (part instanceof ArrayBuffer) return new Uint8Array(part); + if (ArrayBuffer.isView(part)) return new Uint8Array(part.buffer, part.byteOffset, part.byteLength); + if (typeof part === "string") return enc.encode(part); + return enc.encode(String(part)); + }; + + const chunks = parts.map(toU8); + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +const BaseBlob: typeof Blob = RealBlob ?? Blob; + +const mockBlobByteMap = new WeakMap(); + +export const getMockBlobBytes = (x: MockBlob) => { + return mockBlobByteMap.get(x).slice(); // Return a copy to prevent mutation +}; + +export class MockBlob extends BaseBlob { + #data: Uint8Array; + #type: string; + #isConsumed: boolean = false; + + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + this.#data = partsToUint8Array(parts); + this.#type = options?.type ? options.type.toLowerCase() : ""; + mockBlobByteMap.set(this, this.#data); + } + + get size(): number { + return this.#data.byteLength; + } + + get type(): string { + return this.#type; + } + + async text(): Promise { + if (this.#isConsumed) throw new TypeError("Blob stream already consumed"); + return new TextDecoder().decode(this.#data); + } + + async arrayBuffer(): Promise { + if (this.#isConsumed) throw new TypeError("Blob stream already consumed"); + return this.#data.slice().buffer; + } + + slice(a?: number, b?: number, contentType?: string): Blob { + const normalizedStart = a == null ? 0 : a < 0 ? Math.max(this.size + a, 0) : Math.min(a, this.size); + const normalizedEnd = b == null ? this.size : b < 0 ? Math.max(this.size + b, 0) : Math.min(b, this.size); + const slicedData = this.#data.slice(normalizedStart, Math.max(normalizedEnd, normalizedStart)); + return new MockBlob([slicedData], { type: contentType ?? this.#type }); + } + + stream(): ReadableStream { + if (this.#isConsumed) throw new TypeError("Blob stream already consumed"); + this.#isConsumed = true; + return new ReadableStream({ + start: (controller) => { + if (this.#data.length) controller.enqueue(this.#data); + controller.close(); + }, + }); + } + + async bytes(): Promise { + if (this.#isConsumed) throw new TypeError("Blob stream already consumed"); + return this.#data.slice(); + } +} diff --git a/tests/mocks/fetch.ts b/tests/mocks/fetch.ts new file mode 100644 index 000000000..7c4419006 --- /dev/null +++ b/tests/mocks/fetch.ts @@ -0,0 +1,32 @@ +import { vi } from "vitest"; +import { MockRequest } from "./request"; +import { MockBlob } from "./blob"; +import { getMockNetworkResponse, MockResponse } from "./response"; +import { setNetworkRequestCounter } from "./network"; + +// --- Mock Fetch --- +export const mockFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = input instanceof MockRequest ? input : new MockRequest(input, init); + + // Check for abort + if (request.signal.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + + // Get mock response + const { data, contentType, blob } = getMockNetworkResponse(request.url); + const body = blob ? new MockBlob([data], { type: contentType }) : data; + + const ret = new MockResponse(body, { + status: 200, + headers: { "Content-Type": contentType }, + url: request.url, + }); + + if (typeof input === "string") { + setNetworkRequestCounter(input); + } + + // @ts-expect-error + return ret; +}); diff --git a/tests/mocks/network.ts b/tests/mocks/network.ts new file mode 100644 index 000000000..36344fdd7 --- /dev/null +++ b/tests/mocks/network.ts @@ -0,0 +1,33 @@ +import { newMockXhr } from "mock-xmlhttprequest"; +import type EventEmitter from "eventemitter3"; +import type MockXhrRequest from "node_modules/mock-xmlhttprequest/dist/cjs/MockXhrRequest.d.cts"; + +export const setNetworkRequestCounter = (url: string) => { + const wbr = chrome.webRequest.onBeforeRequest as any; + const EE: EventEmitter | undefined = wbr?.EE; + if (EE) { + wbr.counter ||= 0; + const counter = ++wbr.counter; + EE.emit("onBeforeRequest", { + tabId: -1, + requestId: counter, + url: url, + initiator: `chrome-extension://${chrome.runtime.id}`, + timeStamp: Date.now(), + }); + } +}; + +// const realFetch = fetch; + +export const mockNetwork = ({ onSend }: { onSend: (request: MockXhrRequest, ...args: any[]) => any }) => { + const mockXhr = newMockXhr(); + const originalOnSend = onSend || mockXhr.onSend; + mockXhr.onSend = function (request, ...args: any[]) { + // @ts-ignore + const ret = originalOnSend?.apply(this, [request, ...args]); + setNetworkRequestCounter(request.url); + return ret; + }; + return { mockXhr }; +}; diff --git a/tests/mocks/request.ts b/tests/mocks/request.ts new file mode 100644 index 000000000..b4cc8d3c4 --- /dev/null +++ b/tests/mocks/request.ts @@ -0,0 +1,145 @@ +import { getMockBlobBytes, MockBlob } from "./blob"; + +export class MockRequest implements Request { + readonly url: string; + readonly method: string; + readonly headers: Headers; + readonly bodyUsed: boolean = false; + readonly signal: AbortSignal; + readonly credentials: RequestCredentials = "same-origin"; + readonly cache: RequestCache = "default"; + readonly redirect: RequestRedirect = "follow"; + readonly referrer: string = ""; + readonly referrerPolicy: ReferrerPolicy = ""; + readonly integrity: string = ""; + readonly keepalive: boolean = false; + readonly mode: RequestMode = "cors"; + readonly destination: RequestDestination = ""; + readonly isHistoryNavigation: boolean = false; + readonly isReloadNavigation: boolean = false; + // @ts-expect-error + readonly body: ReadableStream | null; + #bytes: Uint8Array | null; + + constructor(input: RequestInfo | URL, init?: RequestInit) { + if (typeof input === "string") { + this.url = new URL(input, "http://localhost").toString(); + } else if (input instanceof URL) { + this.url = input.toString(); + } else if (input instanceof MockRequest) { + this.url = input.url; + } else { + throw new TypeError("Invalid input for Request constructor"); + } + + this.method = (init?.method ?? (input instanceof MockRequest ? input.method : "GET")).toUpperCase(); + this.headers = new Headers(init?.headers ?? (input instanceof MockRequest ? input.headers : undefined)); + this.signal = init?.signal ?? (input instanceof MockRequest ? input.signal : new AbortController().signal); + this.credentials = init?.credentials ?? (input instanceof MockRequest ? input.credentials : "same-origin"); + this.cache = init?.cache ?? (input instanceof MockRequest ? input.cache : "default"); + this.redirect = init?.redirect ?? (input instanceof MockRequest ? input.redirect : "follow"); + this.referrer = init?.referrer ?? (input instanceof MockRequest ? input.referrer : ""); + this.referrerPolicy = init?.referrerPolicy ?? (input instanceof MockRequest ? input.referrerPolicy : ""); + this.integrity = init?.integrity ?? (input instanceof MockRequest ? input.integrity : ""); + this.keepalive = init?.keepalive ?? (input instanceof MockRequest ? input.keepalive : false); + this.mode = init?.mode ?? (input instanceof MockRequest ? input.mode : "cors"); + + let bodyInit: BodyInit | null | undefined = init?.body ?? (input instanceof MockRequest ? input.body : null); + if (["GET", "HEAD"].includes(this.method)) bodyInit = null; + + if (bodyInit instanceof Uint8Array) { + this.#bytes = bodyInit; + } else if (bodyInit instanceof ArrayBuffer) { + this.#bytes = new Uint8Array(bodyInit); + } else if (typeof bodyInit === "string") { + this.#bytes = new TextEncoder().encode(bodyInit); + } else if (bodyInit instanceof MockBlob) { + this.#bytes = getMockBlobBytes(bodyInit); // Use public method + } else if (bodyInit instanceof FormData || bodyInit instanceof URLSearchParams) { + this.#bytes = new TextEncoder().encode(bodyInit.toString()); + } else { + this.#bytes = null; + } + + this.body = this.#bytes + ? new ReadableStream({ + start: (controller) => { + controller.enqueue(this.#bytes!); + controller.close(); + }, + pull: () => { + (this as any).bodyUsed = true; + }, + cancel: () => { + (this as any).bodyUsed = true; + }, + }) + : null; + } + + async arrayBuffer(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + return this.#bytes?.slice().buffer ?? new ArrayBuffer(0); + } + + async blob(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + // @ts-expect-error + return new MockBlob([this.#bytes ?? new Uint8Array(0)]); + } + + async formData(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + const formData = new FormData(); + if (this.#bytes) { + const text = new TextDecoder().decode(this.#bytes); + try { + const params = new URLSearchParams(text); + params.forEach((value, key) => formData.append(key, value)); + } catch { + // Non-URLSearchParams body + } + } + return formData; + } + + async json(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + if (!this.#bytes) return null; + const text = new TextDecoder().decode(this.#bytes); + try { + return JSON.parse(text); + } catch { + throw new SyntaxError("Invalid JSON"); + } + } + + async text(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + return this.#bytes ? new TextDecoder().decode(this.#bytes) : ""; + } + + clone(): Request { + if (this.bodyUsed) throw new TypeError("Cannot clone: Body already consumed"); + // @ts-expect-error + return new MockRequest(this, { + method: this.method, + headers: this.headers, + body: this.#bytes ? new Uint8Array(this.#bytes) : null, + signal: this.signal, + credentials: this.credentials, + cache: this.cache, + redirect: this.redirect, + referrer: this.referrer, + referrerPolicy: this.referrerPolicy, + integrity: this.integrity, + keepalive: this.keepalive, + mode: this.mode, + }); + } +} diff --git a/tests/mocks/response.ts b/tests/mocks/response.ts new file mode 100644 index 000000000..450c7f4f0 --- /dev/null +++ b/tests/mocks/response.ts @@ -0,0 +1,125 @@ +import { getMockBlobBytes, MockBlob } from "./blob"; + +const mockNetworkResponses = new Map(); + +export const setMockNetworkResponse = (url: string, v: any) => { + mockNetworkResponses.set(url, v); +}; + +export const getMockNetworkResponse = (url: string) => { + return mockNetworkResponses.get(url); +}; + +export class MockResponse implements Response { + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; + readonly redirected: boolean = false; + readonly type: ResponseType = "basic"; + readonly headers: Headers; + // @ts-expect-error + readonly body: ReadableStream | null; + bodyUsed: boolean = false; + #bytes: Uint8Array; + + constructor(body?: BodyInit | null, init?: ResponseInit & { url?: string }) { + // Normalize body to bytes + if (body instanceof Uint8Array) { + this.#bytes = body; + } else if (body instanceof ArrayBuffer) { + this.#bytes = new Uint8Array(body); + } else if (typeof body === "string") { + this.#bytes = new TextEncoder().encode(body); + } else if (body instanceof MockBlob) { + this.#bytes = getMockBlobBytes(body); // Use public method + } else if (body instanceof FormData || body instanceof URLSearchParams) { + this.#bytes = new TextEncoder().encode(body.toString()); + } else { + this.#bytes = new Uint8Array(0); + } + + this.status = init?.status ?? 200; + this.statusText = init?.statusText ?? (this.status === 200 ? "OK" : ""); + this.ok = this.status >= 200 && this.status < 300; + this.headers = new Headers(init?.headers); + // Set Content-Type for Blob bodies if not provided + if (body instanceof MockBlob && !this.headers.has("Content-Type")) { + this.headers.set("Content-Type", body.type || "application/octet-stream"); + } + this.url = init?.url ?? ""; + + this.body = this.#bytes.length + ? new ReadableStream({ + start: (controller) => { + controller.enqueue(this.#bytes); + controller.close(); + }, + pull: () => { + (this as any).bodyUsed = true; + }, + cancel: () => { + (this as any).bodyUsed = true; + }, + }) + : null; + } + + async arrayBuffer(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + return this.#bytes.slice().buffer; + } + + async blob(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + // @ts-expect-error + return new MockBlob([this.#bytes], { type: this.headers.get("Content-Type") || "" }); + } + + async formData(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + const formData = new FormData(); + if (this.#bytes.length) { + const text = new TextDecoder().decode(this.#bytes); + try { + const params = new URLSearchParams(text); + params.forEach((value, key) => formData.append(key, value)); + } catch { + // Non-URLSearchParams body + } + } + return formData; + } + + async json(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + if (!this.#bytes.length) return null; + const text = new TextDecoder().decode(this.#bytes); + try { + return JSON.parse(text); + } catch { + throw new SyntaxError("Invalid JSON"); + } + } + + async text(): Promise { + if (this.bodyUsed) throw new TypeError("Body already consumed"); + (this as any).bodyUsed = true; + return new TextDecoder().decode(this.#bytes); + } + + clone(): Response { + if (this.bodyUsed) throw new TypeError("Cannot clone: Body already consumed"); + // @ts-expect-error + return new MockResponse(this.#bytes.slice(), { + status: this.status, + statusText: this.statusText, + headers: this.headers, + url: this.url, + }); + } +} diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index 6352c8574..b4a820133 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -1,11 +1,20 @@ import { type Script, ScriptDAO, type ScriptRunResource } from "@App/app/repo/scripts"; -import GMApi from "@App/app/service/content/gm_api"; -import { initTestGMApi } from "@Tests/utils"; +import GMApi from "@App/app/service/content/gm_api/gm_api"; import { randomUUID } from "crypto"; -import { newMockXhr } from "mock-xmlhttprequest"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi, vitest } from "vitest"; +import { addTestPermission, initTestGMApi } from "@Tests/utils"; +import { mockNetwork } from "@Tests/mocks/network"; +import { setMockNetworkResponse } from "@Tests/mocks/response"; -const msg = initTestGMApi(); +const customXhrResponseMap = new Map< + string, + { + responseHeaders: Record; + responseContent: any; + } +>(); + +const realXMLHttpRequest = global.XMLHttpRequest; const script: Script = { uuid: randomUUID(), @@ -28,59 +37,312 @@ const script: Script = { beforeAll(async () => { await new ScriptDAO().save(script); + const { mockXhr } = mockNetwork({ + onSend: async (request) => { + const customResponse = customXhrResponseMap.get(request.url); + if (customResponse) { + return request.respond(200, customResponse.responseHeaders, customResponse.responseContent); + } + switch (request.url) { + case "https://www.example.com/": + return request.respond(200, {}, "example"); + case window.location.href: + return request.respond(200, {}, "location"); + case "https://example.com/json": + return request.respond(200, { "Content-Type": "application/json" }, JSON.stringify({ test: 1 })); + case "https://www.example.com/header": + if (request.requestHeaders.getHeader("x-nonce") !== "123456") { + return request.respond(403, {}, "bad"); + } + return request.respond(200, {}, "header"); + case "https://www.example.com/unsafeHeader": + if ( + request.requestHeaders.getHeader("Origin") !== "https://example.com" || + request.requestHeaders.getHeader("Cookie") !== "website=example.com" + ) { + return request.respond(400, {}, "bad request"); + } + return request.respond(200, { "Set-Cookie": "test=1" }, "unsafeHeader"); + case "https://www.wexample.com/unsafeHeader/cookie": + if (request.requestHeaders.getHeader("Cookie") !== "test=1") { + return request.respond(400, {}, "bad request"); + } + return request.respond(200, {}, "unsafeHeader/cookie"); + case "https://www.example.com/notexist": + return request.respond(404, {}, "404 not found"); + } + if (request.method === "POST") { + switch (request.url) { + case "https://example.com/form": + if (request.body.get("blob")) { + return request.respond( + 200, + { "Content-Type": "text/html" }, + // mock 一个blob对象 + { + text: () => Promise.resolve("form"), + } + ); + } + return request.respond(400, {}, "bad"); + } + } + return request.respond(200, {}, "test"); + }, + }); + vi.stubGlobal("XMLHttpRequest", mockXhr); }); -describe("GM xmlHttpRequest", () => { +afterAll(() => { + vi.stubGlobal("XMLHttpRequest", realXMLHttpRequest); +}); + +describe.concurrent("测试GMApi环境 - XHR", async () => { + const msg = initTestGMApi(); + const script: Script = { + uuid: randomUUID(), + name: "test", + metadata: { + grant: [ + // gm xhr + "GM_xmlhttpRequest", + ], + connect: ["example.com"], + }, + namespace: "", + type: 1, + status: 1, + sort: 0, + runStatus: "running", + createtime: 0, + checktime: 0, + }; + + addTestPermission(script.uuid); + await new ScriptDAO().save(script); const gmApi = new GMApi("serviceWorker", msg, { uuid: script.uuid, }); - const mockXhr = newMockXhr(); - mockXhr.onSend = async (request) => { - switch (request.url) { - case "https://www.example.com/": - return request.respond(200, {}, "example"); - case window.location.href: - return request.respond(200, {}, "location"); - case "https://example.com/json": - return request.respond(200, { "Content-Type": "application/json" }, JSON.stringify({ test: 1 })); - case "https://www.example.com/header": - if (request.requestHeaders.getHeader("x-nonce") !== "123456") { - return request.respond(403, {}, "bad"); - } - return request.respond(200, {}, "header"); - case "https://www.example.com/unsafeHeader": - if ( - request.requestHeaders.getHeader("Origin") !== "https://example.com" || - request.requestHeaders.getHeader("Cookie") !== "website=example.com" - ) { - return request.respond(400, {}, "bad request"); - } - return request.respond(200, { "Set-Cookie": "test=1" }, "unsafeHeader"); - case "https://www.wexample.com/unsafeHeader/cookie": - if (request.requestHeaders.getHeader("Cookie") !== "test=1") { - return request.respond(400, {}, "bad request"); - } - return request.respond(200, {}, "unsafeHeader/cookie"); - } - if (request.method === "POST") { - switch (request.url) { - case "https://example.com/form": - if (request.body.get("blob")) { - return request.respond( - 200, - { "Content-Type": "text/html" }, - // mock 一个blob对象 - { - text: () => Promise.resolve("form"), - } - ); + it.concurrent("test GM xhr - plain text", async () => { + const testUrl = "https://mock-xmlhttprequest-plain.test/"; + customXhrResponseMap.set(testUrl, { + responseHeaders: {}, + responseContent: "example", + }); + const onload = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: testUrl, + onload: (res) => { + resolve(true); + onload(res.responseText); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + customXhrResponseMap.delete(testUrl); + expect(onload).toBeCalled(); + expect(onload.mock.calls[0][0]).toBe("example"); + }); + it.concurrent("test GM xhr - plain text [fetch]", async () => { + const testUrl = "https://mock-xmlhttprequest-plain-fetch.test/"; + setMockNetworkResponse(testUrl, { + data: "Response for GET https://mock-xmlhttprequest-plain-fetch.test/", + contentType: "text/plain", + }); + const onload = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + fetch: true, + url: testUrl, + onload: (res) => { + resolve(true); + onload(res.responseText); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + expect(onload).toBeCalled(); + expect(onload.mock.calls[0][0]).toBe("Response for GET https://mock-xmlhttprequest-plain-fetch.test/"); + }); + it.concurrent("test GM xhr - blob", async () => { + // Define a simple HTML page as a string + const htmlContent = ` + + + + Blob HTML Example + + +

Hello from a Blob!

+

This HTML page is generated from a JavaScript Blob object.

+ + + `; + + // Create a Blob object from the HTML string + const blob = new Blob([htmlContent], { type: "text/html" }); + + const testUrl = "https://mock-xmlhttprequest-blob.test/"; + customXhrResponseMap.set(testUrl, { + responseHeaders: {}, + responseContent: blob, + }); + // const fn1 = vitest.fn(); + // const fn2 = vitest.fn(); + const onload = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: testUrl, + responseType: "blob", + onload: (res) => { + onload(res); + // if (!(res.response instanceof Blob)) { + // resolve(false); + // return; + // } + // fn2(res.response); + // (res.response as Blob).text().then((text) => { + // resolve(true); + // fn1(text); + // }); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + customXhrResponseMap.delete(testUrl); + expect(onload).toBeCalled(); + // expect(fn1).toBeCalled(); + // expect(fn1.mock.calls[0][0]).toBe(htmlContent); + // expect(fn2.mock.calls[0][0]).not.toBe(blob); + }); + + it.concurrent("test GM xhr - blob [fetch]", async () => { + // Define a simple HTML page as a string + const htmlContent = ` + + + + Blob HTML Example + + +

Hello from a Blob!

+

This HTML page is generated from a JavaScript Blob object.

+ + +`; + + // Create a Blob object from the HTML string + const blob = new Blob([htmlContent], { type: "text/html" }); + + setMockNetworkResponse("https://mock-xmlhttprequest.test/", { + data: htmlContent, + contentType: "text/html", + blob: true, + }); + const fn1 = vitest.fn(); + const fn2 = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + fetch: true, + responseType: "blob", + url: "https://mock-xmlhttprequest.test/", + onload: (res) => { + if (!(res.response instanceof Blob)) { + resolve(false); + return; } - return request.respond(400, {}, "bad"); - } - } - return request.respond(200, {}, "test"); - }; - vi.stubGlobal("XMLHttpRequest", mockXhr); + fn2(res.response); + (res.response as Blob).text().then((text) => { + resolve(true); + fn1(text); + }); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + expect(fn1).toBeCalled(); + expect(fn1.mock.calls[0][0]).toBe(htmlContent); + expect(fn2.mock.calls[0][0]).not.toBe(blob); + }); + + it.concurrent("test GM xhr - json", async () => { + // Create a Blob object from the HTML string + const jsonObj = { code: 100, result: { a: 3, b: [2, 4], c: ["1", "2", "4"], d: { e: [1, 3], f: "4" } } }; + const jsonObjStr = JSON.stringify(jsonObj); + + const testUrl = "https://mock-xmlhttprequest-json.test/"; + customXhrResponseMap.set(testUrl, { + responseHeaders: { "Content-Type": "application/json" }, + responseContent: jsonObjStr, + }); + const fn1 = vitest.fn(); + const fn2 = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: testUrl, + responseType: "json", + onload: (res) => { + resolve(true); + fn1(res.responseText); + fn2(res.response); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + customXhrResponseMap.delete(testUrl); + expect(fn1).toBeCalled(); + expect(fn1.mock.calls[0][0]).toBe(jsonObjStr); + expect(fn2.mock.calls[0][0]).toStrictEqual(jsonObj); + }); + + it.concurrent("test GM xhr - json [fetch]", async () => { + // Create a Blob object from the HTML string + const jsonObj = { code: 100, result: { a: 3, b: [2, 4], c: ["1", "2", "4"], d: { e: [1, 3], f: "4" } } }; + const jsonObjStr = JSON.stringify(jsonObj); + const testUrl = "https://mock-xmlhttprequest-json-fetch.test/"; + setMockNetworkResponse(testUrl, { + data: jsonObjStr, + contentType: "application/json", + }); + const fn1 = vitest.fn(); + const fn2 = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + fetch: true, + url: testUrl, + responseType: "json", + onload: (res) => { + resolve(true); + fn1(res.responseText); + fn2(res.response); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + expect(fn1).toBeCalled(); + expect(fn1.mock.calls[0][0]).toBe(jsonObjStr); + expect(fn2.mock.calls[0][0]).toStrictEqual(jsonObj); + }); +}); + +describe.concurrent("GM xmlHttpRequest", () => { + const msg = initTestGMApi(); + const gmApi = new GMApi("serviceWorker", msg, { + uuid: script.uuid, + }); it.concurrent("get", () => { return new Promise((resolve) => { gmApi.GM_xmlhttpRequest({ @@ -139,4 +401,17 @@ describe("GM xmlHttpRequest", () => { }); }); }); + it.concurrent("404", async () => { + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://www.example.com/notexist", + method: "GET", + onload: (resp) => { + expect(resp.status).toBe(404); + expect(resp.responseText).toBe("404 not found"); + resolve(); + }, + }); + }); + }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts deleted file mode 100644 index e1e602f38..000000000 --- a/tests/utils.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vitest, vi } from "vitest"; -import { initTestGMApi } from "./utils"; -import { randomUUID } from "crypto"; -import { newMockXhr } from "mock-xmlhttprequest"; -import type { Script, ScriptRunResource } from "@App/app/repo/scripts"; -import { ScriptDAO } from "@App/app/repo/scripts"; -import GMApi from "@App/app/service/content/gm_api"; - -describe("测试GMApi环境", async () => { - const msg = initTestGMApi(); - const script: Script = { - uuid: randomUUID(), - name: "test", - metadata: { - grant: [ - // gm xhr - "GM_xmlhttpRequest", - ], - connect: ["example.com"], - }, - namespace: "", - type: 1, - status: 1, - sort: 0, - runStatus: "running", - createtime: 0, - checktime: 0, - }; - await new ScriptDAO().save(script); - const gmApi = new GMApi("serviceWorker", msg, { - uuid: script.uuid, - }); - const mockXhr = newMockXhr(); - mockXhr.onSend = async (request) => { - return request.respond(200, {}, "example"); - }; - vi.stubGlobal("XMLHttpRequest", mockXhr); - it.concurrent("test GM xhr", async () => { - const onload = vitest.fn(); - await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ - url: "https://example.com/", - onload: (res) => { - console.log(res); - resolve(res); - onload(res.responseText); - }, - }); - }); - expect(onload).toBeCalled(); - expect(onload.mock.calls[0][0]).toBe("example"); - }); -}); diff --git a/tests/utils.ts b/tests/utils.ts index b8e7d65d8..bb5232ca3 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,15 +1,18 @@ import LoggerCore, { EmptyWriter } from "@App/app/logger/core"; import { MockMessage } from "@Packages/message/mock_message"; +import type { IGetSender } from "@Packages/message/server"; import { Server } from "@Packages/message/server"; import type { Message } from "@Packages/message/types"; import { ValueService } from "@App/app/service/service_worker/value"; -import GMApi, { MockGMExternalDependencies } from "@App/app/service/service_worker/gm_api"; +import GMApi, { MockGMExternalDependencies } from "@App/app/service/service_worker/gm_api/gm_api"; import OffscreenGMApi from "@App/app/service/offscreen/gm_api"; import EventEmitter from "eventemitter3"; import "@Packages/chrome-extension-mock"; import { MessageQueue } from "@Packages/message/message_queue"; import { SystemConfig } from "@App/pkg/config/config"; +import type { ApiValue } from "@App/app/service/service_worker/permission_verify"; import PermissionVerify from "@App/app/service/service_worker/permission_verify"; +import type { GMApiRequest } from "@App/app/service/service_worker/types"; export function initTestEnv() { // @ts-ignore @@ -19,25 +22,6 @@ export function initTestEnv() { // @ts-ignore global.initTest = true; - const OldBlob = Blob; - // @ts-ignore - global.Blob = function Blob(data, options) { - const blob = new OldBlob(data, options); - blob.text = () => Promise.resolve(data[0]); - blob.arrayBuffer = () => { - return new Promise((resolve) => { - const str = data[0]; - const buf = new ArrayBuffer(str.length * 2); // 每个字符占用2个字节 - const bufView = new Uint16Array(buf); - for (let i = 0, strLen = str.length; i < strLen; i += 1) { - bufView[i] = str.charCodeAt(i); - } - resolve(buf); - }); - }; - return blob; - }; - const logger = new LoggerCore({ level: "trace", consoleLevel: "trace", @@ -47,6 +31,11 @@ export function initTestEnv() { logger.logger().debug("test start"); } +const noConfirmScripts = new Set(); +export const addTestPermission = (uuid: string) => { + noConfirmScripts.add(uuid); +}; + export function initTestGMApi(): Message { const wsEE = new EventEmitter(); const wsMessage = new MockMessage(wsEE); @@ -58,6 +47,11 @@ export function initTestGMApi(): Message { const serviceWorkerServer = new Server("serviceWorker", wsMessage); const valueService = new ValueService(serviceWorkerServer.group("value"), messageQueue); const permissionVerify = new PermissionVerify(serviceWorkerServer.group("permissionVerify"), messageQueue); + (permissionVerify as any).confirmWindowActual = permissionVerify.confirmWindow; + (permissionVerify as any).verify = function (request: GMApiRequest, _api: ApiValue, _sender: IGetSender) { + if (noConfirmScripts.has(request.uuid)) return true; + return false; + }; const swGMApi = new GMApi( systemConfig, permissionVerify, diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 4264d75c2..2027a6341 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -2,6 +2,10 @@ import chromeMock from "@Packages/chrome-extension-mock"; import { initTestEnv } from "./utils"; import "@testing-library/jest-dom/vitest"; import { vi } from "vitest"; +import { MockRequest } from "./mocks/request"; +import { MockBlob } from "./mocks/blob"; +import { MockResponse } from "./mocks/response"; +import { mockFetch } from "./mocks/fetch"; vi.stubGlobal("chrome", chromeMock); chromeMock.init(); @@ -118,4 +122,10 @@ vi.stubGlobal("sandboxTestValue2", "sandboxTestValue2"); vi.stubGlobal("ttest1", 1); vi.stubGlobal("ttest2", 2); +// Install globals +vi.stubGlobal("fetch", mockFetch); +vi.stubGlobal("Request", MockRequest); +vi.stubGlobal("Response", MockResponse); +vi.stubGlobal("Blob", MockBlob); + vi.stubGlobal("define", "特殊关键字不能穿透沙盒");