From 294a04ba74d3447df921614246766c47be163bbc Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:29:18 +0900 Subject: [PATCH 01/44] =?UTF-8?q?=E9=87=8D=E6=9E=84=20`GM=5FxmlhttpRequest?= =?UTF-8?q?`=20=E5=8F=8A=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- packages/chrome-extension-mock/runtime.ts | 1 + .../chrome-extension-mock/web_reqeuest.ts | 79 +- packages/network-mock.ts | 33 + src/app/service/content/gm_api.ts | 712 +++++++--- src/app/service/offscreen/gm_api.ts | 91 +- src/app/service/service_worker/gm_api.test.ts | 38 +- src/app/service/service_worker/gm_api.ts | 1166 ++++++++++++----- src/app/service/service_worker/index.ts | 3 +- .../service_worker/permission_verify.ts | 11 +- src/app/service/service_worker/resource.ts | 6 +- .../service_worker/script_update_check.ts | 1 - src/app/service/service_worker/system.ts | 3 +- src/app/service/service_worker/types.ts | 3 +- .../service/service_worker/xhr_interface.ts | 121 ++ src/locales/locales.ts | 6 +- src/offscreen.ts | 3 + src/pkg/utils/opfs.ts | 15 + src/pkg/utils/opfs_impl.ts | 49 + src/pkg/utils/script.ts | 3 +- src/pkg/utils/sw_fetch.ts | 25 + src/pkg/utils/utils.ts | 8 + src/pkg/utils/utils_datatype.ts | 88 ++ src/pkg/utils/uuid.ts | 3 + src/pkg/utils/xhr_bg_core.ts | 882 +++++++++++++ src/pkg/utils/xhr_data.ts | 232 ++++ src/service_worker.ts | 4 + src/types/main.d.ts | 21 +- src/types/scriptcat.d.ts | 10 +- tests/runtime/gm_api.test.ts | 369 +++++- tests/shared.ts | 9 + tests/utils.test.ts | 53 - tests/utils.ts | 51 +- tests/vitest.setup.ts | 431 ++++++ 34 files changed, 3746 insertions(+), 786 deletions(-) create mode 100644 packages/network-mock.ts create mode 100644 src/app/service/service_worker/xhr_interface.ts create mode 100644 src/pkg/utils/opfs.ts create mode 100644 src/pkg/utils/opfs_impl.ts create mode 100644 src/pkg/utils/sw_fetch.ts create mode 100644 src/pkg/utils/utils_datatype.ts create mode 100644 src/pkg/utils/uuid.ts create mode 100644 src/pkg/utils/xhr_bg_core.ts create mode 100644 src/pkg/utils/xhr_data.ts create mode 100644 tests/shared.ts delete mode 100644 tests/utils.test.ts diff --git a/package.json b/package.json index 3a4b7baf4..4944e0786 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "preinstall": "pnpm dlx only-allow pnpm", - "test": "vitest", + "test": "vitest --test-timeout=500", "coverage": "vitest run --coverage", "build": "cross-env NODE_ENV=production rspack build", "dev": "cross-env NODE_ENV=development rspack", diff --git a/packages/chrome-extension-mock/runtime.ts b/packages/chrome-extension-mock/runtime.ts index 112eb7d2e..6c314b754 100644 --- a/packages/chrome-extension-mock/runtime.ts +++ b/packages/chrome-extension-mock/runtime.ts @@ -4,6 +4,7 @@ type Port = chrome.runtime.Port & { }; export default class Runtime { + id = "kfjdomqetnlhbgxasrwzypcviueotmlr"; connectListener: Array<(port: chrome.runtime.Port) => void> = []; messageListener: Array<(message: any) => void> = []; diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index ccd1ef7bf..8209a6d38 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -1,36 +1,38 @@ +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; - }; - } + // 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) => { @@ -49,4 +51,21 @@ 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 + }, + }; } diff --git a/packages/network-mock.ts b/packages/network-mock.ts new file mode 100644 index 000000000..36344fdd7 --- /dev/null +++ b/packages/network-mock.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/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 3eaf219f9..f29d871ca 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -21,6 +21,9 @@ import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "./listener_manager"; import { decodeMessage, encodeMessage } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; +import { base64ToUint8, concatUint8 } from "@App/pkg/utils/utils_datatype"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { dataEncode } from "@App/pkg/utils/xhr_data"; // 内部函数呼叫定义 export interface IGM_Base { @@ -30,6 +33,38 @@ export interface IGM_Base { emitEvent(event: string, eventId: string, data: any): void; } +export interface GMRequestHandle { + /** Abort the ongoing request */ + abort: () => void; +} + +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; + 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; +}; + +type GMXHRResponseTypeWithError = GMXHRResponseType & Required>; + const integrity = {}; // 仅防止非法实例化 let valChangeCounterId = 0; @@ -47,6 +82,78 @@ const execEnvInit = (execEnv: GMApi) => { } }; +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 */ +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); + }); +}; + +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; +}; + +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; +}; + +// const urlToDocumentLocal = async (a: GMApi, url: string) => { +// if (typeof XMLHttpRequest === "undefined") return urlToDocumentInContentPage(a, url); +// return new Promise((resolve) => { +// const xhr = new XMLHttpRequest(); +// xhr.responseType = "document"; +// xhr.open("GET", url); +// xhr.onload = () => { +// const doc = xhr.response instanceof Document ? xhr.response : null; +// resolve(doc); +// }; +// xhr.send(); +// }); +// }; + +// const strToDocument = async (a: GMApi, text: string, contentType: DOMParserSupportedType) => { +// if (typeof DOMParser === "function") { +// // 前台環境(CONTENT/MAIN) +// // str -> Document (CONTENT/MAIN) +// // Document物件是在API呼叫環境產生 +// return new DOMParser().parseFromString(text, contentType); +// } else { +// // fallback: 以 urlToDocumentInContentPage 方式取得 +// const blob = new Blob([text], { type: contentType }); +// const blobURL = await toBlobURL(a, blob); +// const document = await urlToDocumentInContentPage(a, blobURL); +// return document; +// } +// }; + // GM_Base 定义内部用变量和函数。均使用@protected // 暂不考虑 Object.getOwnPropertyNames(GM_Base.prototype) 和 ts-morph 脚本生成 class GM_Base implements IGM_Base { @@ -457,7 +564,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 +575,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( @@ -757,7 +863,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,13 +889,24 @@ export default class GMApi extends GM_Base { }); } - static _GM_xmlhttpRequest(a: GMApi, details: GMTypes.XHRDetails) { + static _GM_xmlhttpRequest(a: GMApi, details: GMTypes.XHRDetails, requirePromise: boolean) { + let reqDone = false; if (a.isInvalidContext()) { return { + retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, abort: () => {}, }; } - const u = new URL(details.url, window.location.href); + 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)) { @@ -803,7 +920,7 @@ export default class GMApi extends GM_Base { const param: GMSend.XHRDetails = { method: details.method, timeout: details.timeout, - url: u.href, + url: "", headers: details.headers, cookie: details.cookie, context: details.context, @@ -821,170 +938,449 @@ export default class GMApi extends GM_Base { if (details.nocache) { param.headers["Cache-Control"] = "no-cache"; } - let connect: MessageConnect; + let connect: MessageConnect | null; + const responseTypeOriginal = details.responseType?.toLocaleLowerCase() || ""; + let doAbort: any = null; 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; - } + 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对象 - 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); - } + if (responseTypeOriginal === "stream") { + readerStream = new ReadableStream({ + start(ctrl) { + controller = ctrl; + }, + }); + } else { // document类型读取blob,然后在content页转化为document对象 - if (responseType === "document") { - param.responseType = "blob"; - } - if (responseType === "stream") { - if (details.onloadstart) { - details.onloadstart = warpResponse(details.onloadstart); - } + 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("GM_xmlhttpRequest", [param]).then((con) => { + // 注意。在此 callback 裡,不應直接存取 param, 否則會影響 GC 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({ + const resultTexts = [] as string[]; + const resultBuffers = [] as Uint8Array[]; + let finalResultBuffers: Uint8Array | null = null; + const asyncTaskId = `${Date.now}:${Math.random()}`; + + 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; + + 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 && + resultBuffers.length === 0 && + resultTexts.length === 0) || + 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: "", + }; + } + if (resError) { + return { + 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; + } + const param = { + 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; + } + } + } + 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 + responseText = `${resultTexts.join("")}`; + } + } + return responseText as string; + }, + toString: () => "[object Object]", // follow TM + } as GMXHRResponseType; + if (res.error) { + param.error = res.error; + } + if (responseType === "json" && param.response === null) { + response = undefined; // TM不使用null,使用undefined + } + return param; + }; + doAbort = (data: any) => { + if (!reqDone) { + errorOccur = "AbortError"; + details.onabort?.(makeXHRCallbackParam(data)); + reqDone = true; + } + }; + + con.onMessage((msgData) => { + 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: data.message || "unknown", + error: msgData.message || "unknown", }); + return; } - return; - } - // 处理返回 - switch (data.action) { - case "onload": - details.onload?.(data.data); - break; - case "onloadend": - details.onloadend?.(data.data); - break; - case "onloadstart": - details.onloadstart?.(data.data); - break; - case "onprogress": - details.onprogress?.(data.data); - break; - case "onreadystatechange": - details.onreadystatechange && details.onreadystatechange(data.data); - break; - case "ontimeout": - details.ontimeout?.(); - break; - case "onerror": - details.onerror?.(data.data); - break; - case "onabort": - details.onabort?.(); - break; - case "onstream": - controller?.enqueue(new Uint8Array(data.data)); - break; - default: - LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", { - data, - }); - break; - } + // 处理返回 + switch (msgData.action) { + case "reset_chunk_arraybuffer": + case "reset_chunk_blob": + case "reset_chunk_buffer": { + resultBuffers.length = 0; + break; + } + case "reset_chunk_document": + case "reset_chunk_json": + case "reset_chunk_text": { + resultTexts.length = 0; + break; + } + case "append_chunk_stream": { + const d = msgData.data.chunk as string; + const u8 = base64ToUint8(d); + resultBuffers.push(u8); + 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); + 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); + resultType = 3; + break; + } + case "onload": + details.onload?.(makeXHRCallbackParam(data)); + break; + case "onloadend": { + reqDone = true; + const xhrReponse = makeXHRCallbackParam(data); + details.onloadend?.(xhrReponse); + if (errorOccur === null) { + retPromiseResolve?.(xhrReponse); + } else { + retPromiseReject?.(errorOccur); + } + 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": { + if (data.readyState === 4 && data.ok) { + 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; + } + break; + case "onerror": + if (!reqDone) { + data.error ||= "Unknown Error"; + errorOccur = data.error; + details.onerror?.(makeXHRCallbackParam(data) as GMXHRResponseTypeWithError); + reqDone = true; + } + 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; + } + }); }); }); }; // 由于需要同步返回一个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; } }, }; @@ -995,29 +1391,15 @@ export default class GMApi extends GM_Base { depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"], }) public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - return _GM_xmlhttpRequest(this, details); + 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 { - 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(); - }; + 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; } diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts index b397fc918..37c234dee 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -2,6 +2,7 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; import type { IGetSender, Group } from "@Packages/message/server"; import type { MessageConnect } from "@Packages/message/types"; +import { bgXhrInterface } from "../service_worker/xhr_interface"; export default class GMApi { logger: Logger = LoggerCore.logger().with({ service: "gmApi" }); @@ -88,95 +89,9 @@ export default class GMApi { } 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"); + bgXhrInterface(details, { finalUrl: "", responseHeaders: "" }, con); } 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.test.ts index 06a778ad9..0ac0f6c11 100644 --- a/src/app/service/service_worker/gm_api.test.ts +++ b/src/app/service/service_worker/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("isConnectMatched", () => { it("回传 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('遇到 "*" 应回传 true', () => { const req = new URL("https://anything.example.com/path"); - expect(isConnectMatched(["*"], req, makeSender())).toBe(true); + expect(getConnectMatched(["*"], req, makeSender())).toBe(ConnectMatch.ALL); }); it("尾缀网域比对成功时回传 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("尾缀网域比对成功时回传 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('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('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('当 sender.getSender() 回传没有 url 或无效 URL 时,"self" 不应报错且回传 false(若无其他规则命中)', () => { @@ -55,29 +55,29 @@ describe("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('当 "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("完全不匹配时回传 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("域名不区分大小写", () => { 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.ts index 85b766832..98d3a08b3 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -2,7 +2,7 @@ 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 { 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"; @@ -12,7 +12,7 @@ import PermissionVerify, { PermissionVerifyApiGet } from "./permission_verify"; import { cacheInstance } from "@App/app/cache"; import EventEmitter from "eventemitter3"; import { type RuntimeService } from "./runtime"; -import { getIcon, isFirefox, openInCurrentTab, cleanFileName } from "@App/pkg/utils/utils"; +import { getIcon, isFirefox, openInCurrentTab, cleanFileName, urlSanitize } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; import i18next, { i18nName } from "@App/locales/locales"; import FileSystemFactory from "@Packages/filesystem/factory"; @@ -33,13 +33,82 @@ 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 { bgXhrInterface } from "./xhr_interface"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; + +const askUnlistedConnect = false; +const askConnectStar = true; + +const scXhrRequests = new Map(); // 关联SC后台发出的 xhr/fetch 的 requestId +const redirectedUrls = new Map(); // 关联SC后台发出的 xhr/fetch 的 redirectUrl +const nwErrorResults = new Map(); // 关联SC后台发出的 xhr/fetch 的 network error +const nwErrorResultPromises = new Map(); +// net::ERR_NAME_NOT_RESOLVED, net::ERR_CONNECTION_REFUSED, net::ERR_ABORTED, net::ERR_FAILED + +// 接收 xhr/fetch 的 responseHeaders +const headersReceivedMap = new Map< + string, + { responseHeaders: chrome.webRequest.HttpHeader[] | undefined | null; statusCode: number | null } +>(); +// 特殊方式处理:以 DNR Rule per request 方式处理 header 修改 (e.g. cookie, unsafeHeader) +const headerModifierMap = new Map< + string, + { + rule: chrome.declarativeNetRequest.Rule; + redirectNotManual: boolean; + } +>(); + +type TXhrReqObject = { + reqUrl: string; + markerId: string; + resolve?: ((value?: unknown) => void) | null; + startTime: number; +}; + +const xhrReqEntries = new Map(); + +const setReqDone = (stdUrl: string, xhrReqEntry: TXhrReqObject) => { + xhrReqEntry.reqUrl = ""; + xhrReqEntry.markerId = ""; + xhrReqEntry.startTime = 0; + xhrReqEntry.resolve?.(); + xhrReqEntry.resolve = null; + xhrReqEntries.delete(stdUrl); +}; + +const setReqId = (reqId: string, url: string, timeStamp: number) => { + const stdUrl = urlSanitize(url); + const xhrReqEntry = xhrReqEntries.get(stdUrl); + if (xhrReqEntry) { + const { reqUrl, markerId } = xhrReqEntry; + if (reqUrl !== url && `URL::${urlSanitize(reqUrl)}` !== `URL::${stdUrl}`) { + // 通常不会发生 + console.error("xhrReqEntry URL mistached", reqUrl, url); + setReqDone(stdUrl, xhrReqEntry); + } else if (!xhrReqEntry.startTime || !(timeStamp > xhrReqEntry.startTime)) { + // 通常不会发生 + console.error("xhrReqEntry timeStamp issue 1", xhrReqEntry.startTime, timeStamp); + setReqDone(stdUrl, xhrReqEntry); + } else if (timeStamp - xhrReqEntry.startTime > 400) { + // 通常不会发生 + console.error("xhrReqEntry timeStamp issue 2", xhrReqEntry.startTime, timeStamp); + setReqDone(stdUrl, xhrReqEntry); + } else { + // console.log("xhrReqEntry", xhrReqEntry.startTime, timeStamp); // 相隔 2 ~ 9 ms + scXhrRequests.set(markerId, reqId); // 同时存放 (markerID -> reqId) + scXhrRequests.set(reqId, markerId); // 同时存放 (reqId -> markerID) + setReqDone(stdUrl, xhrReqEntry); + } + } +}; // GMApi,处理脚本的GM API调用请求 type RequestResultParams = { - requestId: number; statusCode: number; - responseHeader: string; + responseHeaders: string; + finalUrl: string; }; type OnBeforeSendHeadersOptions = `${chrome.webRequest.OnBeforeSendHeadersOptions}`; @@ -106,7 +175,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 +200,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 = { @@ -155,6 +237,17 @@ export class MockGMExternalDependencies implements IGMExternalDependencies { } } +const supportedRequestMethods = new Set([ + "connect", + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", +]); + export default class GMApi { logger: Logger; @@ -215,7 +308,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 +563,53 @@ 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; + // 采用legacy命名方式,以大写,X- 开头 + // 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"]); + // 不使用"X-SC-Request-Marker", 避免 modifyHeaders DNR 和 chrome.webRequest.onBeforeSendHeaders 的执行次序问题 - // 根据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 +628,196 @@ 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; - } + const redirectNotManual = params.redirect !== "manual"; - gmXhrHeadersReceived = new EventEmitter(); - - 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; - } - const reader = resp.body?.getReader(); - if (!reader) { - throw new Error("read is not found"); - } - 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); - } - }; - 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, - }); - }); - } + // 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.responseHeaders || respond.responseHeaders; + // } + // return respond; + // } + + // 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; + // } + // const reader = resp.body?.getReader(); + // if (!reader) { + // throw new Error("read is not found"); + // } + // const readData = ({ done, value }: { done: boolean; value?: Uint8Array }) => { + // if (done) { + // const data = this.dealFetch(config, resp, 4, resultParam); + // data.responseHeaders = resultParam.responseHeaders || 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); + // } + // }; + // reader.read().then(readData); + // send.responseHeaders = resultParam.responseHeaders || 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 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 = 0x30; + return false; + } } const metadata: { [key: string]: string } = {}; metadata[i18next.t("script_name")] = i18nName(request.script); @@ -746,77 +837,246 @@ 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 u1 = Math.floor(Date.now()).toString(36); + const u2 = Math.floor(Math.random() * 2514670967279938 + 1045564536402193).toString(36); + const markerID = `MARKER::${u1}_${u2}`; + + const isRedirectError = request.params?.[0]?.redirect === "error"; - param1.headers["X-Scriptcat-GM-XHR-Request-Id"] = requestId.toString(); - param1.headers = await this.buildDNRRule(requestId, param1, sender); + let resultParamStatusCode = 0; + let resultParamResponseHeader = ""; + let resultParamFinalUrl = ""; const resultParam: RequestResultParams = { - requestId, - statusCode: 0, - responseHeader: "", + get statusCode() { + const responsed = headersReceivedMap.get(markerID); + if (responsed && typeof responsed.statusCode === "number") { + resultParamStatusCode = responsed.statusCode; + responsed.statusCode = null; // 设为 null 避免重复处理 + } + return resultParamStatusCode; + }, + get responseHeaders() { + const responsed = headersReceivedMap.get(markerID); + if (responsed && responsed.responseHeaders) { + let s = ""; + for (const h of responsed.responseHeaders) { + s += `${h.name}: ${h.value}\n`; + } + resultParamResponseHeader = s; + responsed.responseHeaders = null; // 设为 null 避免重复处理 + } + return resultParamResponseHeader; + }, + get finalUrl() { + resultParamFinalUrl = redirectedUrls.get(markerID) || ""; + return resultParamFinalUrl; + }, }; - 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 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); - } - if (!sender.isType(GetSenderType.CONNECT)) { - throw new Error("GM_xmlhttpRequest ERROR: sender is not MessageConnect"); + return new Error(`${error}`); + }; + + const param1 = request.params[0]; + if (!param1) { + throw throwErrorFn("param is failed"); } - const msgConn = sender.getConnect(); - if (!msgConn) { - throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined"); + if (request.extraCode === 0x30) { + // 'Refused to connect to "https://nonexistent-domain-abcxyz.test/": This domain is not a part of the @connect list' + // 'Refused to connect to "https://example.org/": URL is blacklisted' + const msg = `Refused to connect to "${param1.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; + try { + // 先处理unsafe hearder + + // 处理cookiePartition + // 详见 https://github.com/scriptscat/scriptcat/issues/392 + // https://github.com/scriptscat/scriptcat/commit/3774aa3acebeadb6b08162625a9af29a9599fa96 + if (!param1.cookiePartition || typeof param1.cookiePartition !== "object") { + param1.cookiePartition = {}; } - msgConn.sendMessage(msg); - }); - msgConn.onDisconnect(() => { - // 关闭连接 - offscreenCon.disconnect(); - }); + if (typeof param1.cookiePartition.topLevelSite !== "string") { + // string | undefined + param1.cookiePartition.topLevelSite = undefined; + } + + // 添加请求header + await this.buildDNRRule(markerID, param1, sender); + // let finalUrl = ""; + // 等待response + + let useFetch; + { + const anonymous = param1.anonymous ?? param1.mozAnon ?? false; + + const redirect = param1.redirect; + + const isFetch = param1.fetch ?? false; + + const isBufferStream = param1.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); + }; + const requestUrl = param1.url; + const stdUrl = urlSanitize(requestUrl); // 确保 url 能执行 urlSanitize 且不会报错 + + const f = async () => { + if (useFetch) { + // 只有fetch支持ReadableStream、redirect这些,直接使用fetch + // return this.CAT_fetch(param1, sender, resultParam); + + bgXhrInterface( + param1, + { + get finalUrl() { + return resultParam.finalUrl; + }, + get responseHeaders() { + return resultParam.responseHeaders; + }, + get status() { + return resultParam.statusCode; + }, + loadendCleanUp() { + loadendCleanUp(); + }, + async 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; + }> + ) { + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { + resultParamStatusCode = msg.data.status; + } + if (msg.data?.status === 301) { + // 兼容TM - redirect: manual 显示原网址 + redirectedUrls.delete(markerID); + resultParamFinalUrl = requestUrl; + msg.data.finalUrl = requestUrl; + } else if (msg.action === "onerror" && isRedirectError && msg.data) { + let nwErr = nwErrorResults.get(markerID); + if (!nwErr) { + // 等 Network Error 捕捉 + await Promise.race([ + new Promise((resolve) => { + nwErrorResultPromises.set(markerID, resolve); + }), + new Promise((r) => setTimeout(r, 800)), + ]); + nwErr = nwErrorResults.get(markerID); + } + if (nwErr) { + msg.data.status = 408; + msg.data.statusText = ""; + msg.data.responseHeaders = ""; + } + } + }, + }, + msgConn + ); + return; + } + // 再发送到offscreen, 处理请求 + const offscreenCon = await connect(this.msgSender, "offscreen/gmApi/xmlHttpRequest", param1); + offscreenCon.onMessage((msg) => { + // 发送到content + let data = msg.data; + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { + 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(); + }); + }; + + // stackAsyncTask 是为了 chrome.webRequest.onBeforeRequest 能捕捉当前 XHR 的 id + // 旧SC使用 modiftyHeader DNR Rule + chrome.webRequest.onBeforeSendHeaders 捕捉 + // 但这种方式可能会随DNR规范改变而失效,因为 modiftyHeader DNR Rule 不保证必定发生在 onBeforeSendHeaders 前 + await stackAsyncTask(`nwRequest::${stdUrl}`, async () => { + const xhrReqEntry = { + reqUrl: requestUrl, + markerId: markerID, + startTime: Date.now() - 1, // -1 to avoid floating number rounding + } as TXhrReqObject; + const ret = new Promise((resolve) => { + xhrReqEntry.resolve = resolve; + }); + xhrReqEntries.set(stdUrl, xhrReqEntry); + try { + await f(); + } catch { + setReqDone(stdUrl, xhrReqEntry); + } + return ret; + }); + } catch (e: any) { + throw throwErrorFn(`GM_xmlhttpRequest ERROR: ${e?.message || e || "Unknown Error"}`); + } } @PermissionVerify.API({ alias: ["CAT_registerMenuInput"] }) @@ -1077,26 +1337,59 @@ export default class GMApi { if (!msgConn) { throw new Error("GM_download ERROR: msgConn is undefined"); } + let isConnDisconnected = false; + msgConn.onDisconnect(() => { + isConnDisconnected = true; + }); const params = request.params[0]; // 替换掉windows下文件名的非法字符为 - const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 - if (params.url.startsWith("blob:") || params.downloadMode === "browser") { + const startDownload = (blobURL: string, respond: any) => { + if (!blobURL) { + !isConnDisconnected && + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); + throw new Error("GM_download ERROR: blobURL is not provided."); + } chrome.downloads.download( { - url: params.url, + url: blobURL, saveAs: params.saveAs, filename: fileName, }, - () => { + (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 (!isConnDisconnected) { + if (ok) { + msgConn.sendMessage({ + action: "onload", + data: respond, + }); + } else { + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); + } } - msgConn.sendMessage({ action: "onload" }); } ); + }; + if (params.url.startsWith("blob:") || params.downloadMode === "browser") { + startDownload(params.url, null); return; } // 使用xhr下载blob,再使用download api创建下载 @@ -1111,43 +1404,53 @@ export default class GMApi { statusText: xhr.statusText, responseHeaders: xhr.responseHeaders, }; + let msgToSend = null; switch (data.action) { - case "onload": - msgConn.sendMessage({ - action: "onload", - data: respond, - }); - chrome.downloads.download({ - url: xhr.response, - saveAs: params.saveAs, - filename: fileName, - }); + case "onload": { + const response = xhr.response; + let url = ""; + if (response instanceof Blob) { + url = URL.createObjectURL(response); + } else if (typeof response === "string") { + url = response; + } + startDownload(url, respond); break; + } case "onerror": - msgConn.sendMessage({ + msgToSend = { 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; - msgConn.sendMessage({ + respond.totalSize = xhr.total; // ?????? + msgToSend = { action: "onprogress", data: respond, - }); + }; break; case "ontimeout": - msgConn.sendMessage({ + msgToSend = { action: "ontimeout", - }); + }; break; + case "onloadend": + msgToSend = { + action: "onloadend", + data: respond, + }; + break; + } + if (!isConnDisconnected && msgToSend) { + msgConn.sendMessage(msgToSend); } }); - return this.GM_xmlhttpRequest( + const ret = this.GM_xmlhttpRequest( { ...request, params: [ @@ -1165,6 +1468,10 @@ export default class GMApi { }, new SenderConnect(mockConnect) ); + msgConn.onDisconnect(() => { + // To be implemented + }); + return ret; } @PermissionVerify.API() @@ -1250,12 +1557,160 @@ export default class GMApi { // 处理GM_xmlhttpRequest请求 handlerGmXhr() { + chrome.webRequest.onBeforeRequest.addListener( + (details) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRequest:", lastError); + // webRequest API 出错不进行后续处理 + return undefined; + } + if (xhrReqEntries.size) { + if ( + details.tabId === -1 && + details.requestId && + details.url && + (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) && + !scXhrRequests.has(details.requestId) + ) { + setReqId(details.requestId, details.url, details.timeStamp); + } + } + }, + { + urls: [""], + types: ["xmlhttprequest"], + tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen + } + ); + + // chrome.declarativeNetRequest.updateSessionRules({ + // removeRuleIds: [9001], + // addRules: [ + // { + // id: 9001, + // action: { + // type: "modifyHeaders", + // requestHeaders: [ + // { + // header: "X-SC-Request-Marker", + // operation: "remove", + // }, + // ], + // }, + // priority: 1, + // condition: { + // resourceTypes: ["xmlhttprequest"], + // // 不要指定 requestMethods。 这个DNR是对所有后台发出的xhr请求, 即使它是 HEAD,DELETE,也要捕捉 + // tabIds: [chrome.tabs.TAB_ID_NONE], // 只限于后台 service_worker / offscreen + // }, + // }, + // ], + // }); + + 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"); - } + // 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 (xhrReqEntries.size) { + // if ( + // details.tabId === -1 && + // details.requestId && + // details.url && + // (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) && + // !scXhrRequests.has(details.requestId) + // ) { + // setReqId(details.requestId, details.url, details.timeStamp); + // } + // } + 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 +1720,47 @@ 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 markerID = scXhrRequests.get(reqId); + if (!markerID) return; + redirectedUrls.set(markerID, details.url); + + // if (myRequests.has(details.requestId)) { + // const markerID = myRequests.get(details.requestId); + // if (markerID) { + // redirectedUrls.set(markerID, details.url); + // } + // } else { + // // Chrome: 目前 modifyHeaders DNR 会较 chrome.webRequest.onBeforeSendHeaders 后执行 + // // 如日后API行为改变,需要改用 onBeforeRequest,且每次等 fetch/xhr 触发 onBeforeRequest 后才能执行下一个 fetch/xhr + // const headers = details.requestHeaders; + // // 讲请求id与chrome.webRequest的请求id关联 + // if (headers) { + // // 自订header可能会被转为小写,例如fetch API + // // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers + // if (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) { + // const idx = headers.findIndex((h) => h.name.toLowerCase() === "x-sc-request-marker"); + // if (idx !== -1) { + // const markerID = headers[idx].value; + // if (typeof markerID === "string") { + // // 请求id关联 + // const reqId = details.requestId; + // myRequests.set(markerID, reqId); // 同时存放 (markerID -> reqId) + // myRequests.set(reqId, markerID); // 同时存放 (reqId -> markerID) + // redirectedUrls.set(markerID, details.url); + // } + // } + // } + // } + // } } return undefined; }, { urls: [""], types: ["xmlhttprequest"], + tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen }, reqOpt ); @@ -1291,56 +1773,68 @@ 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" - ); - // 设置重定向url,获取到实际的请求地址 - this.cache.set("gmXhrRequest:finalUrl:" + requestId, location); + // 使用 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], + }); + } else { + // 删除关联与DNR + headerModifierMap.delete(markerID); chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [parseInt(requestId)], - addRules: [rule], + removeRuleIds: [rule.id], }); - 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; diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index ee22d0d32..b338fe8ef 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -18,6 +18,7 @@ import { localePath, t } from "@App/locales/locales"; import { getCurrentTab, InfoNotification } from "@App/pkg/utils/utils"; import { onTabRemoved, onUrlNavigated, setOnUserActionDomainChanged } from "./url_monitor"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; +import { swFetch } from "@App/pkg/utils/sw_fetch"; // service worker的管理器 export default class ServiceWorkerManager { @@ -265,7 +266,7 @@ export default class ServiceWorkerManager { } checkUpdate() { - fetch(`${ExtServer}api/v1/system/version?version=${ExtVersion}`) + swFetch(`${ExtServer}api/v1/system/version?version=${ExtVersion}`) .then((resp) => resp.json()) .then((resp: { data: { notice: string; version: string } }) => { systemConfig diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index d2bfffe55..4c0b761f3 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -111,12 +111,18 @@ export default class PermissionVerify { this.permissionDAO.enableCache(); } + noVerify(_request: GMApiRequest, _api: ApiValue, _sender: IGetSender) { + // 测试用 + return false; + } + // 验证是否有权限 async verify(request: GMApiRequest, api: ApiValue, sender: IGetSender): Promise { const { alias, link, confirm } = api.param; if (api.param.default) { return true; } + if (this.noVerify(request, api, sender)) return true; // 没有其它条件,从metadata.grant中判断 const { grant } = request.script.metadata; if (!grant) { @@ -133,11 +139,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); } - return result; + return true; } } throw new Error("permission not requested"); diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 9e06eb89b..a7c402672 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -12,6 +12,8 @@ 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 { swFetch } from "@App/pkg/utils/sw_fetch"; +import { blobToUint8Array } from "@App/pkg/utils/utils_datatype"; export class ResourceService { logger: Logger; @@ -257,14 +259,14 @@ export class ResourceService { async loadByUrl(url: string, type: ResourceType): Promise { const u = parseUrlSRI(url); - const resp = await fetch(u.url); + const resp = await swFetch(u.url); if (resp.status !== 200) { throw new Error(`resource response status not 200: ${resp.status}`); } 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/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/system.ts b/src/app/service/service_worker/system.ts index 0679228f0..3eff26f24 100644 --- a/src/app/service/service_worker/system.ts +++ b/src/app/service/service_worker/system.ts @@ -5,6 +5,7 @@ import { createObjectURL, VscodeConnectClient } from "../offscreen/client"; import { cacheInstance } from "@App/app/cache"; import { CACHE_KEY_FAVICON } from "@App/app/cache_key"; import { fetchIconByDomain } from "./fetch"; +import { swFetch } from "@App/pkg/utils/sw_fetch"; // 一些系统服务 export class SystemService { @@ -28,7 +29,7 @@ export class SystemService { // 对url做一个缓存 const cacheKey = `${CACHE_KEY_FAVICON}${url}`; return cacheInstance.getOrSet(cacheKey, async () => { - return fetch(url) + return swFetch(url) .then((response) => response.blob()) .then((blob) => createObjectURL(this.msgSender, blob, true)) .catch(() => { 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/app/service/service_worker/xhr_interface.ts b/src/app/service/service_worker/xhr_interface.ts new file mode 100644 index 000000000..bf60fd3fd --- /dev/null +++ b/src/app/service/service_worker/xhr_interface.ts @@ -0,0 +1,121 @@ +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/utils_datatype"; +import { bgXhrRequestFn } from "@App/pkg/utils/xhr_bg_core"; +import { type MessageConnect, type TMessageCommAction } from "@Packages/message/types"; + +/** + * 把 bgXhrRequestFn 的执行结果通过 MessageConnect 进一步传到 service_worker / offscreen + * Communicate Network Request in Background + * @param param1 Input + * @param inRef Control + * @param msgConn Connection + */ +export const bgXhrInterface = (param1: any, inRef: any, msgConn: MessageConnect) => { + const taskId = `${Date.now}:${Math.random()}`; + let isConnDisconnected = false; + const settings = { + onDataReceived: (param: { chunk: boolean; type: string; data: any }) => { + stackAsyncTask(taskId, async () => { + if (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: {}, + }; + msgConn.sendMessage(msg); + } + for (const chunk of chunks) { + const msg: TMessageCommAction = { + action: `append_chunk_${param.type}`, + data: { + chunk: uint8ToBase64(chunk), + }, + }; + 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: {}, + }; + 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, + }, + }; + msgConn.sendMessage(msg); + } + } + } + } catch (e: any) { + console.error(e); + } + }); + }, + callback: ( + result: 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; + } + ) => { + const data = { + ...result, + finalUrl: inRef.finalUrl, + responseHeaders: inRef.responseHeaders || result.responseHeaders || "", + }; + const eventType = result.eventType; + const msg: TMessageCommAction = { + action: `on${eventType}`, + data: data, + }; + stackAsyncTask(taskId, async () => { + await inRef.fixMsg?.(msg); + if (eventType === "loadend") { + inRef.loadendCleanUp?.(); + } + if (isConnDisconnected) return; + msgConn.sendMessage(msg); + }); + }, + } as Record & { abort?: () => void }; + bgXhrRequestFn(param1, settings).catch((e: any) => { + settings.abort?.(); + console.error(e); + }); + msgConn.onDisconnect(() => { + isConnDisconnected = true; + settings.abort?.(); + // console.warn("msgConn.onDisconnect"); + }); +}; 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/offscreen.ts b/src/offscreen.ts index dffec141c..234d4d7b8 100644 --- a/src/offscreen.ts +++ b/src/offscreen.ts @@ -3,8 +3,11 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { OffscreenManager } from "./app/service/offscreen"; import { ExtensionMessage } from "@Packages/message/extension_message"; +// import * as OPFS from "./pkg/utils/opfs_impl"; +// import { assignOPFS } from "./pkg/utils/opfs"; function main() { + // assignOPFS(OPFS); // 初始化日志组件 const extMsgSender: Message = new ExtensionMessage(); const loggerCore = new LoggerCore({ diff --git a/src/pkg/utils/opfs.ts b/src/pkg/utils/opfs.ts new file mode 100644 index 000000000..7a94e110b --- /dev/null +++ b/src/pkg/utils/opfs.ts @@ -0,0 +1,15 @@ +// 避免直接把OPFS打包到 content.js / inject.js +import type * as K from "./opfs_impl"; + +// runtime vars that will be assigned once +export let getOPFSRoot!: typeof K.getOPFSRoot; +export let setOPFSTemp!: typeof K.setOPFSTemp; +export let getOPFSTemp!: typeof K.getOPFSTemp; +export let initOPFS!: typeof K.initOPFS; + +export function assignOPFS(impl: typeof K) { + getOPFSRoot = impl.getOPFSRoot; + setOPFSTemp = impl.setOPFSTemp; + getOPFSTemp = impl.getOPFSTemp; + initOPFS = impl.initOPFS; +} diff --git a/src/pkg/utils/opfs_impl.ts b/src/pkg/utils/opfs_impl.ts new file mode 100644 index 000000000..2025b71ab --- /dev/null +++ b/src/pkg/utils/opfs_impl.ts @@ -0,0 +1,49 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; + +// 注意:應只在 service_worker/offscreen 使用,而不要在 page/content 使用 +// 檔案只存放在 chrome-extension:/// (sandbox) + +const TEMP_FOLDER = "SC_TEMP_FILES"; + +const o = { + OPFS_ROOT: null, +} as { + OPFS_ROOT: FileSystemDirectoryHandle | null; +}; +export const getOPFSRoot = async () => { + o.OPFS_ROOT ||= await navigator.storage.getDirectory(); + return o.OPFS_ROOT; +}; +export const initOPFS = async () => { + o.OPFS_ROOT ||= await navigator.storage.getDirectory(); + const OPFS_ROOT = await getOPFSRoot(); + try { + await OPFS_ROOT.removeEntry(TEMP_FOLDER, { recursive: true }); + } catch { + // e.g. NotFoundError - ignore + } +}; +export const setOPFSTemp = async (data: string | BufferSource | Blob | WriteParams) => { + o.OPFS_ROOT ||= await navigator.storage.getDirectory(); + const OPFS_ROOT = o.OPFS_ROOT; + const filename = uuidv4(); + const directoryHandle = await OPFS_ROOT.getDirectoryHandle(TEMP_FOLDER, { create: true }); + const handle = await directoryHandle.getFileHandle(filename, { create: true }); + const writable = await handle.createWritable(); + await writable.write(data); + await writable.close(); + return filename; +}; + +export const getOPFSTemp = async (filename: string): Promise => { + o.OPFS_ROOT ||= await navigator.storage.getDirectory(); + const OPFS_ROOT = o.OPFS_ROOT; + try { + const directoryHandle = await OPFS_ROOT.getDirectoryHandle(TEMP_FOLDER); + const handle = await directoryHandle.getFileHandle(filename); + const file = await handle.getFile(); + return file; + } catch { + return null; + } +}; diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index e8219607e..10891ab40 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -15,6 +15,7 @@ import { SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe"; import { nextTime } from "./cron"; import { parseUserConfig } from "./yaml"; import { t as i18n_t } from "@App/locales/locales"; +import { swFetch } from "./sw_fetch"; // 从脚本代码抽出Metadata export function parseMetadata(code: string): SCMetadata | null { @@ -59,7 +60,7 @@ export function parseMetadata(code: string): SCMetadata | null { // 从网址取得脚本代码 export async function fetchScriptBody(url: string): Promise { - const resp = await fetch(url, { + const resp = await swFetch(url, { headers: { "Cache-Control": "no-cache", }, diff --git a/src/pkg/utils/sw_fetch.ts b/src/pkg/utils/sw_fetch.ts new file mode 100644 index 000000000..c3e0833a4 --- /dev/null +++ b/src/pkg/utils/sw_fetch.ts @@ -0,0 +1,25 @@ +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { urlSanitize } from "@App/pkg/utils/utils"; + +export const swFetch = (input: string | URL | Request, init?: RequestInit) => { + let url; + if (typeof input === "string") { + url = input; + } else if (typeof (input as any)?.href === "string") { + url = (input as any).href; + } else if (typeof (input as any)?.url === "string") { + url = (input as any).url; + } + let stdUrl; + if (url) { + try { + stdUrl = urlSanitize(url); + } catch { + // ignored + } + } + if (!stdUrl) return fetch(input, init); + // 鎖一下 nwRequest 防止與 GM_xhr 竞争 + + return stackAsyncTask(`nwRequest::${stdUrl}`, () => fetch(input, init)); +}; diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index d4b62df4b..a157f11e4 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -369,3 +369,11 @@ export const stringMatching = (main: string, sub: string): boolean => { return false; } }; + +export const urlSanitize = (url: string) => { + const u = new URL(url); // 利用 URL 處理 URL Encoding 問題。 + // 例如 'https://日月.baidu.com/你好' => 'https://xn--wgv4y.baidu.com/%E4%BD%A0%E5%A5%BD' + // 為方便控制,只需要考慮 orign 和 pathname 的匹對 + // https://user:passwd@httpbun.com/basic-auth/user/passwd -> https://httpbun.com/basic-auth/user/passwd + return `URL::${u.origin}${u.pathname}`; +}; diff --git a/src/pkg/utils/utils_datatype.ts b/src/pkg/utils/utils_datatype.ts new file mode 100644 index 000000000..e713e27f4 --- /dev/null +++ b/src/pkg/utils/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_core.ts b/src/pkg/utils/xhr_bg_core.ts new file mode 100644 index 000000000..2a754619d --- /dev/null +++ b/src/pkg/utils/xhr_bg_core.ts @@ -0,0 +1,882 @@ +// console.log('streaming ' + (GM_xmlhttpRequest.RESPONSE_TYPE_STREAM === 'stream' ? 'supported' : 'not supported'); + +import { dataDecode } from "./xhr_data"; + +/** + * ## 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` | (v5.2+) Cookie partition key. | + * | `topLevelSite` | `string` | 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. + */ + +/** + * Represents the response object returned to GM_xmlhttpRequest callbacks. + */ +export interface GMResponse { + /** The final URL after redirects */ + finalUrl: string; + /** Current ready state */ + readyState: number; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Raw response headers */ + responseHeaders: string; + /** Parsed response data (depends on responseType) */ + response: T; + /** Response as XML document (if applicable) */ + responseXML?: Document; + /** Response as plain text */ + responseText: string; + /** Context object passed from the request */ + context?: any; +} + +type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + +/** + * Represents the request details passed to GM_xmlhttpRequest. + */ +export interface XmlhttpRequestFnDetails { + /** HTTP method (GET, POST, PUT, DELETE, etc.) */ + method?: string; + /** Target url string */ + url: string; + /** Optional headers to include */ + headers?: Record; + /** Data to send with the request */ + data?: GMXHRDataType; + /** Redirect handling mode */ + redirect?: "follow" | "error" | "manual"; + /** Additional cookie to include */ + cookie?: string; + /** Partition key for partitioned cookies (v5.2+) */ + cookiePartition?: Record; + /** Top-level site for partitioned cookies */ + topLevelSite?: string; + /** Send data as binary */ + binary?: boolean; + /** Disable caching: don’t cache or store the resource at all */ + nocache?: boolean; + /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ + revalidate?: boolean; + /** Timeout in milliseconds */ + timeout?: number; + /** Custom value passed to response.context */ + context?: any; + /** Type of response expected */ + responseType?: "arraybuffer" | "blob" | "json" | "stream" | "" | "text" | "document"; // document for VM2.12.0+ + /** Override MIME type */ + overrideMimeType?: string; + /** Send request without cookies (Greasemonkey) */ + mozAnon?: boolean; + /** Send request without cookies */ + anonymous?: boolean; + /** Use fetch() instead of XMLHttpRequest */ + fetch?: boolean; + /** Username for authentication */ + user?: string; + /** Password for authentication */ + password?: string; + /** [NOT SUPPORTED] upload (Greasemonkey) */ + upload?: never; + /** [NOT SUPPORTED] synchronous (Greasemonkey) */ + synchronous?: never; + + /** Called if the request is aborted */ + onabort?: (response: GMResponse) => void; + /** Called on network error */ + onerror?: (response: GMResponse) => void; + /** Called when loading starts */ + onloadstart?: (response: GMResponse) => void; + /** Called on download progress */ + onprogress?: (response: GMResponse) => void; + /** Called when readyState changes */ + onreadystatechange?: (response: GMResponse) => void; + /** Called on request timeout */ + ontimeout?: (response: GMResponse) => void; + /** Called on successful request completion */ + onload?: (response: GMResponse) => void; +} + +/** + * The return value of GM_xmlhttpRequest — includes an abort() function. + */ +export interface GMRequestHandle { + /** Abort the ongoing request */ + abort: () => void; +} + +type ResponseType = "" | "text" | "json" | "blob" | "arraybuffer" | "document"; + +type ReadyState = + | 0 // UNSENT + | 1 // OPENED + | 2 // HEADERS_RECEIVED + | 3 // LOADING + | 4; // DONE + +interface ProgressLikeEvent { + loaded: number; + total: number; + lengthComputable: boolean; +} + +export class FetchXHR { + private readonly extraOptsFn: any; + private readonly isBufferStream: boolean; + private readonly onDataReceived: any; + constructor(opts: any) { + this.extraOptsFn = opts?.extraOptsFn ?? null; + this.isBufferStream = opts?.isBufferStream ?? false; + this.onDataReceived = opts?.onDataReceived ?? null; + // + } + + // 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: 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 = { + 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" }); + } +} + +/** + * Greasemonkey/Tampermonkey GM_xmlhttpRequest API. + * @example + * GM_xmlhttpRequest({ + * method: 'GET', + * url: 'https://example.com/', + * onload: (res) => console.log(res.responseText), + * }); + */ + +/** + * 在后台实际进行 xhr / fetch 的操作 + * Network Request in Background + * 只接受 "", "text", "arraybuffer", 及 "stream" + * @param details Input + * @param settings Control + */ +export const bgXhrRequestFn = async (details: XmlhttpRequestFnDetails, settings: any) => { + /* + + + +cookie a cookie to be patched into the sent cookie set +cookiePartition v5.2+ object?, containing the partition key to be used for sent and received partitioned cookies +topLevelSite string?, representing the top frame site for partitioned cookies + + binary send the data string in binary mode +nocache don't cache the resource +revalidate revalidate maybe cached content + + +context a property which will be added to the response object + +*/ + 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; + // console.log("useFetch", isFetch, !!redirect, anonymous, isBufferStream); + + const prepareXHR = async () => { + let rawData = (details.data = await details.data); + + // console.log("rawData", rawData); + + const baseXHR = useFetch + ? new FetchXHR({ + extraOptsFn: (opts: Record) => { + 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 + } + }, + isBufferStream, + onDataReceived: settings.onDataReceived, + }) + : new XMLHttpRequest(); + + settings.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") { + settings.onDataReceived({ chunk: false, type: "text", data: xhr.responseText }); + } else if (xhrResponseType === "arraybuffer" && response instanceof ArrayBuffer) { + settings.onDataReceived({ chunk: false, type: "arraybuffer", data: response }); + } + } + } + settings.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, + // 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); + } + } + + 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 --- + // if ( + // rawData instanceof URLSearchParams || + // typeof rawData === "string" || + // rawData instanceof Blob || + // rawData instanceof FormData + // ) { + // requestInit.body = rawData as BodyInit; + // } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { + // // JSON body + // requestInit.body = JSON.stringify(rawData); + // if (!headers.has("Content-Type")) { + // headers.set("Content-Type", "application/json"); + // } + // } + + // // --- Handle cookies (if any) --- + // if (cookie) { + // requestInit.headers ||= {}; + // // if (!headers.has("Cookie")) { + // headers.set("Cookie", cookie); + // // } + // } + + // --- Handle request body --- + if ( + 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 + ) { + // + } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { + if ((baseXHR.getResponseHeader("Content-Type") || "application/json") !== "application/json") { + // JSON body + rawData = JSON.stringify(rawData); + baseXHR.setRequestHeader("Content-Type", "application/json"); + } else { + rawData = undefined; + } + } + + // Send data (if any) + baseXHR.send(rawData ?? null); + }; + + await prepareXHR(); +}; diff --git a/src/pkg/utils/xhr_data.ts b/src/pkg/utils/xhr_data.ts new file mode 100644 index 000000000..47e6ff53b --- /dev/null +++ b/src/pkg/utils/xhr_data.ts @@ -0,0 +1,232 @@ +import { base64ToUint8, uint8ToBase64 } from "./utils_datatype"; +// import { getOPFSTemp, setOPFSTemp } from "./opfs"; + +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/service_worker.ts b/src/service_worker.ts index 853d503a9..e702eaf5b 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -11,6 +11,8 @@ import { fetchIconByDomain } from "./app/service/service_worker/fetch"; import { msgResponse } from "./app/service/service_worker/utils"; import type { RuntimeMessageSender } from "@Packages/message/types"; import { cleanInvalidKeys } from "./app/repo/resource"; +// import * as OPFS from "./pkg/utils/opfs_impl"; +// import { assignOPFS, initOPFS } from "./pkg/utils/opfs"; migrate(); migrateChromeStorage(); @@ -64,6 +66,8 @@ async function setupOffscreenDocument() { function main() { cleanInvalidKeys(); + // assignOPFS(OPFS); + // initOPFS(); // 初始化管理器 const message = new ExtensionMessage(true); // 初始化日志组件 diff --git a/src/types/main.d.ts b/src/types/main.d.ts index ea7685986..708aed30d 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,9 +60,10 @@ 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; + mozAnon?: boolean; fetch?: boolean; user?: string; password?: string; @@ -71,12 +72,22 @@ declare namespace GMSend { redirect?: "follow" | "error" | "manual"; } - 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..8f38a8a72 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -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 { @@ -492,7 +492,7 @@ declare namespace GMTypes { 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; cookie?: string; @@ -513,8 +513,8 @@ declare namespace GMTypes { onloadend?: Listener; onprogress?: Listener; onreadystatechange?: Listener; - ontimeout?: () => void; - onabort?: () => void; + ontimeout?: Listener; + onabort?: Listener; onerror?: (err: string | (XHRResponse & { error: string })) => void; } diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index bc38ec5b8..af87d06f4 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -1,11 +1,18 @@ 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 { mockNetwork } from "@Packages/network-mock"; import { randomUUID } from "crypto"; -import { newMockXhr } from "mock-xmlhttprequest"; -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi, vitest } from "vitest"; +import { addTestPermission, initTestGMApi } from "@Tests/utils"; +import { setMockNetworkResponse } from "@Tests/shared"; -const msg = initTestGMApi(); +const customResponse = { + enabled: false, + responseHeaders: {}, + responseContent: null, +} as Record; + +const realXMLHttpRequest = global.XMLHttpRequest; const script: Script = { uuid: randomUUID(), @@ -27,62 +34,319 @@ const script: Script = { }; beforeAll(async () => { + customResponse.enabled = false; await new ScriptDAO().save(script); + const { mockXhr } = mockNetwork({ + onSend: async (request) => { + if (customResponse.enabled) { + 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"); + } + 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); + customResponse.enabled = false; +}); + +describe("测试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("test GM xhr - plain text", async () => { + customResponse.enabled = true; + customResponse.responseHeaders = {}; + customResponse.responseContent = "example"; + const onload = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://mock-xmlhttprequest.test/", + onload: (res) => { + resolve(true); + onload(res.responseText); + }, + onloadend: () => { + resolve(false); + }, + }); + }); + expect(onload).toBeCalled(); + expect(onload.mock.calls[0][0]).toBe("example"); + }); + it("test GM xhr - plain text [fetch]", async () => { + console.log(100, "测试GMApi环境 - XHR > test GM xhr - plain text [fetch]"); + setMockNetworkResponse("https://mock-xmlhttprequest.test/", { + data: "Response for GET https://mock-xmlhttprequest.test/", + contentType: "text/plain", + }); + const onload = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + fetch: true, + url: "https://mock-xmlhttprequest.test/", + 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.test/"); + }); + it("test GM xhr - blob", async () => { + console.log(100, "test GM xhr - blob"); + // 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" }); + customResponse.enabled = true; + customResponse.responseHeaders = {}; + customResponse.responseContent = blob; + // const fn1 = vitest.fn(); + // const fn2 = vitest.fn(); + const onload = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://mock-xmlhttprequest.test/", + responseType: "blob", + onload: (res) => { + customResponse.responseContent = ""; + 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: () => { + customResponse.responseContent = ""; + resolve(false); + }, + }); + }); + expect(onload).toBeCalled(); + // expect(fn1).toBeCalled(); + // expect(fn1.mock.calls[0][0]).toBe(htmlContent); + // expect(fn2.mock.calls[0][0]).not.toBe(blob); + }); + + it("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"); - }; - global.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("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); + customResponse.enabled = true; + customResponse.responseHeaders = { "Content-Type": "application/json" }; + customResponse.responseContent = jsonObjStr; + const fn1 = vitest.fn(); + const fn2 = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://mock-xmlhttprequest.test/", + responseType: "json", + onload: (res) => { + customResponse.enabled = true; + customResponse.responseHeaders = {}; + customResponse.responseContent = ""; + resolve(true); + fn1(res.responseText); + fn2(res.response); + }, + onloadend: () => { + customResponse.enabled = true; + customResponse.responseHeaders = {}; + customResponse.responseContent = ""; + resolve(false); + }, + }); + }); + expect(fn1).toBeCalled(); + expect(fn1.mock.calls[0][0]).toBe(jsonObjStr); + expect(fn2.mock.calls[0][0]).toStrictEqual(jsonObj); + }); + + it("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); + + setMockNetworkResponse("https://mock-xmlhttprequest.test/", { + data: jsonObjStr, + contentType: "application/json", + }); + const fn1 = vitest.fn(); + const fn2 = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + fetch: true, + url: "https://mock-xmlhttprequest.test/", + responseType: "json", + onload: (res) => { + customResponse.enabled = true; + customResponse.responseHeaders = {}; + customResponse.responseContent = ""; + resolve(true); + fn1(res.responseText); + fn2(res.response); + }, + onloadend: () => { + customResponse.enabled = true; + customResponse.responseHeaders = {}; + customResponse.responseContent = ""; + resolve(false); + }, + }); + }); + expect(fn1).toBeCalled(); + expect(fn1.mock.calls[0][0]).toBe(jsonObjStr); + expect(fn2.mock.calls[0][0]).toStrictEqual(jsonObj); + }); +}); + +describe("GM xmlHttpRequest", () => { + const msg = initTestGMApi(); + const gmApi = new GMApi("serviceWorker", msg, { + uuid: script.uuid, + }); it("get", () => { return new Promise((resolve) => { + customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com", onreadystatechange: (resp) => { @@ -98,6 +362,7 @@ describe("GM xmlHttpRequest", () => { // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 it("json", async () => { await new Promise((resolve) => { + customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://example.com/json", method: "GET", @@ -112,6 +377,7 @@ describe("GM xmlHttpRequest", () => { }); // bad json await new Promise((resolve) => { + customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com/", method: "GET", @@ -126,6 +392,7 @@ describe("GM xmlHttpRequest", () => { }); it("header", async () => { await new Promise((resolve) => { + customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com/header", method: "GET", diff --git a/tests/shared.ts b/tests/shared.ts new file mode 100644 index 000000000..199f187b3 --- /dev/null +++ b/tests/shared.ts @@ -0,0 +1,9 @@ +const mockNetworkResponses = new Map(); + +export const setMockNetworkResponse = (url: string, v: any) => { + mockNetworkResponses.set(url, v); +}; + +export const getMockNetworkResponse = (url: string) => { + return mockNetworkResponses.get(url); +}; diff --git a/tests/utils.test.ts b/tests/utils.test.ts deleted file mode 100644 index 59b2bcdb3..000000000 --- a/tests/utils.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vitest } 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"); - }; - global.XMLHttpRequest = mockXhr; - it("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..aa3410dc2 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,6 +1,6 @@ import LoggerCore, { EmptyWriter } from "@App/app/logger/core"; import { MockMessage } from "@Packages/message/mock_message"; -import { Server } from "@Packages/message/server"; +import { type IGetSender, 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"; @@ -9,7 +9,8 @@ import EventEmitter from "eventemitter3"; import "@Packages/chrome-extension-mock"; import { MessageQueue } from "@Packages/message/message_queue"; import { SystemConfig } from "@App/pkg/config/config"; -import PermissionVerify from "@App/app/service/service_worker/permission_verify"; +import PermissionVerify, { type ApiValue } from "@App/app/service/service_worker/permission_verify"; +import { type GMApiRequest } from "@App/app/service/service_worker/types"; export function initTestEnv() { // @ts-ignore @@ -19,24 +20,24 @@ 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 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", @@ -47,6 +48,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 +64,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.noVerify = 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 0e494be13..ad72b0a97 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -1,6 +1,9 @@ import chromeMock from "@Packages/chrome-extension-mock"; import { initTestEnv } from "./utils"; import "@testing-library/jest-dom/vitest"; +import { beforeAll, afterAll, vi } from "vitest"; +import { getMockNetworkResponse } from "./shared"; +import { setNetworkRequestCounter } from "@Packages/network-mock"; chromeMock.init(); initTestEnv(); @@ -118,3 +121,431 @@ global.ttest2 = 2; //@ts-ignore global.define = "特殊关键字不能穿透沙盒"; + +// ---------------------------------------- Blob ------------------------------------------- +// Keep originals to restore later +const realFetch = globalThis.fetch; +const realRequest = globalThis.Request; +const realResponse = globalThis.Response; +const RealBlob = globalThis.Blob; + +// --- 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; +} + +beforeAll(() => { + // --- Mock Blob --- + const BaseBlob: typeof Blob = + RealBlob ?? + 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(); + }, + }); + } + }; + + const mockBlobByteMap = new WeakMap(); + const getMockBlobBytes = (x: MockBlob) => { + return mockBlobByteMap.get(x).slice(); // Return a copy to prevent mutation + }; + 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)); + // @ts-expect-error + return new MockBlob([slicedData], { type: contentType ?? this.#type }); + } + + // @ts-expect-error + 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(); + }, + }); + } + // @ts-expect-error + async bytes(): Promise { + if (this.#isConsumed) throw new TypeError("Blob stream already consumed"); + return this.#data.slice(); + } + } + + // --- Mock Request --- + 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, + }); + } + } + + // --- Mock Response --- + 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, + }); + } + } + + // --- Mock Fetch --- + 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; + }); + + // Install globals + vi.stubGlobal("fetch", mockFetch); + vi.stubGlobal("Request", MockRequest); + vi.stubGlobal("Response", MockResponse); + vi.stubGlobal("Blob", MockBlob); +}); + +afterAll(() => { + // Restore originals + vi.stubGlobal("fetch", realFetch); + vi.stubGlobal("Request", realRequest); + vi.stubGlobal("Response", realResponse); + vi.stubGlobal("Blob", RealBlob ?? undefined); +}); From 5397a85c6d771a82451718e8993442016c93d1c3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:41:11 +0900 Subject: [PATCH 02/44] =?UTF-8?q?=E5=88=AA=E7=84=A1=E7=94=A8=E4=BB=A3?= =?UTF-8?q?=E7=A2=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/offscreen.ts | 3 --- src/pkg/utils/opfs.ts | 15 ------------ src/pkg/utils/opfs_impl.ts | 49 -------------------------------------- src/pkg/utils/xhr_data.ts | 1 - src/service_worker.ts | 4 ---- 5 files changed, 72 deletions(-) delete mode 100644 src/pkg/utils/opfs.ts delete mode 100644 src/pkg/utils/opfs_impl.ts diff --git a/src/offscreen.ts b/src/offscreen.ts index 234d4d7b8..dffec141c 100644 --- a/src/offscreen.ts +++ b/src/offscreen.ts @@ -3,11 +3,8 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { OffscreenManager } from "./app/service/offscreen"; import { ExtensionMessage } from "@Packages/message/extension_message"; -// import * as OPFS from "./pkg/utils/opfs_impl"; -// import { assignOPFS } from "./pkg/utils/opfs"; function main() { - // assignOPFS(OPFS); // 初始化日志组件 const extMsgSender: Message = new ExtensionMessage(); const loggerCore = new LoggerCore({ diff --git a/src/pkg/utils/opfs.ts b/src/pkg/utils/opfs.ts deleted file mode 100644 index 7a94e110b..000000000 --- a/src/pkg/utils/opfs.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 避免直接把OPFS打包到 content.js / inject.js -import type * as K from "./opfs_impl"; - -// runtime vars that will be assigned once -export let getOPFSRoot!: typeof K.getOPFSRoot; -export let setOPFSTemp!: typeof K.setOPFSTemp; -export let getOPFSTemp!: typeof K.getOPFSTemp; -export let initOPFS!: typeof K.initOPFS; - -export function assignOPFS(impl: typeof K) { - getOPFSRoot = impl.getOPFSRoot; - setOPFSTemp = impl.setOPFSTemp; - getOPFSTemp = impl.getOPFSTemp; - initOPFS = impl.initOPFS; -} diff --git a/src/pkg/utils/opfs_impl.ts b/src/pkg/utils/opfs_impl.ts deleted file mode 100644 index 2025b71ab..000000000 --- a/src/pkg/utils/opfs_impl.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { uuidv4 } from "@App/pkg/utils/uuid"; - -// 注意:應只在 service_worker/offscreen 使用,而不要在 page/content 使用 -// 檔案只存放在 chrome-extension:/// (sandbox) - -const TEMP_FOLDER = "SC_TEMP_FILES"; - -const o = { - OPFS_ROOT: null, -} as { - OPFS_ROOT: FileSystemDirectoryHandle | null; -}; -export const getOPFSRoot = async () => { - o.OPFS_ROOT ||= await navigator.storage.getDirectory(); - return o.OPFS_ROOT; -}; -export const initOPFS = async () => { - o.OPFS_ROOT ||= await navigator.storage.getDirectory(); - const OPFS_ROOT = await getOPFSRoot(); - try { - await OPFS_ROOT.removeEntry(TEMP_FOLDER, { recursive: true }); - } catch { - // e.g. NotFoundError - ignore - } -}; -export const setOPFSTemp = async (data: string | BufferSource | Blob | WriteParams) => { - o.OPFS_ROOT ||= await navigator.storage.getDirectory(); - const OPFS_ROOT = o.OPFS_ROOT; - const filename = uuidv4(); - const directoryHandle = await OPFS_ROOT.getDirectoryHandle(TEMP_FOLDER, { create: true }); - const handle = await directoryHandle.getFileHandle(filename, { create: true }); - const writable = await handle.createWritable(); - await writable.write(data); - await writable.close(); - return filename; -}; - -export const getOPFSTemp = async (filename: string): Promise => { - o.OPFS_ROOT ||= await navigator.storage.getDirectory(); - const OPFS_ROOT = o.OPFS_ROOT; - try { - const directoryHandle = await OPFS_ROOT.getDirectoryHandle(TEMP_FOLDER); - const handle = await directoryHandle.getFileHandle(filename); - const file = await handle.getFile(); - return file; - } catch { - return null; - } -}; diff --git a/src/pkg/utils/xhr_data.ts b/src/pkg/utils/xhr_data.ts index 47e6ff53b..9ffcac618 100644 --- a/src/pkg/utils/xhr_data.ts +++ b/src/pkg/utils/xhr_data.ts @@ -1,5 +1,4 @@ import { base64ToUint8, uint8ToBase64 } from "./utils_datatype"; -// import { getOPFSTemp, setOPFSTemp } from "./opfs"; export const typedArrayTypes = [ Int8Array, diff --git a/src/service_worker.ts b/src/service_worker.ts index e702eaf5b..853d503a9 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -11,8 +11,6 @@ import { fetchIconByDomain } from "./app/service/service_worker/fetch"; import { msgResponse } from "./app/service/service_worker/utils"; import type { RuntimeMessageSender } from "@Packages/message/types"; import { cleanInvalidKeys } from "./app/repo/resource"; -// import * as OPFS from "./pkg/utils/opfs_impl"; -// import { assignOPFS, initOPFS } from "./pkg/utils/opfs"; migrate(); migrateChromeStorage(); @@ -66,8 +64,6 @@ async function setupOffscreenDocument() { function main() { cleanInvalidKeys(); - // assignOPFS(OPFS); - // initOPFS(); // 初始化管理器 const message = new ExtensionMessage(true); // 初始化日志组件 From 4e80910415cde20ba97ce2abc1f982638fa7592c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:55:57 +0900 Subject: [PATCH 03/44] =?UTF-8?q?mock=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/chrome-extension-mock/web_reqeuest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index 8209a6d38..67afd403a 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -68,4 +68,10 @@ export default class WebRequest { // TODO }, }; + + onErrorOccurred = { + addListener: () => { + // TODO + }, + }; } From 1f92f41529e5cdf78991713c8ce1c0a30c2aef82 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:30:42 +0900 Subject: [PATCH 04/44] =?UTF-8?q?=E9=87=8D=E6=9E=84=20`GM=5Fdonwload`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api.ts | 273 ++++++++++++++---- src/app/service/service_worker/gm_api.ts | 193 ++++--------- .../service_worker/permission_verify.ts | 1 - src/template/scriptcat.d.tpl | 2 +- src/types/main.d.ts | 1 + src/types/scriptcat.d.ts | 19 +- 6 files changed, 286 insertions(+), 203 deletions(-) diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index f29d871ca..8e60c730c 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -83,7 +83,7 @@ const execEnvInit = (execEnv: GMApi) => { }; const toBlobURL = (a: GMApi, blob: Blob): Promise | string => { - // content_GMAPI 都應該在前台的內容腳本或真實頁面執行。如果沒有 typeof URL.createObjectURL 才使用信息傳遞交給後台 + // content_GMAPI 都应该在前台的内容脚本或真实页面执行。如果没有 typeof URL.createObjectURL 才使用信息传递交给后台 if (typeof URL.createObjectURL === "function") { return URL.createObjectURL(blob); } else { @@ -111,7 +111,7 @@ const convObjectToURL = async (object: string | URL | Blob | File | undefined | } else if (object instanceof Blob) { // 不使用 blob URL // 1. service worker 不能生成 blob URL - // 2. blob URL 有效期管理麻煩 + // 2. blob URL 有效期管理麻烦 const blob = object; url = await blobToDataURL(blob); @@ -141,9 +141,9 @@ const urlToDocumentInContentPage = async (a: GMApi, url: string) => { // const strToDocument = async (a: GMApi, text: string, contentType: DOMParserSupportedType) => { // if (typeof DOMParser === "function") { -// // 前台環境(CONTENT/MAIN) +// // 前台环境(CONTENT/MAIN) // // str -> Document (CONTENT/MAIN) -// // Document物件是在API呼叫環境產生 +// // Document物件是在API呼叫环境产生 // return new DOMParser().parseFromString(text, contentType); // } else { // // fallback: 以 urlToDocumentInContentPage 方式取得 @@ -680,7 +680,7 @@ export default class GMApi extends GM_Base { // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 menuIdCounter: number | undefined; - // 菜单注冊累计器 - 用於穩定同一Tab不同frame之選項的單獨項目不合併狀態 + // 菜单注册累计器 - 用于稳定同一Tab不同frame之选项的单独项目不合并状态 // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 regMenuCounter: number | undefined; @@ -889,7 +889,12 @@ export default class GMApi extends GM_Base { }); } - static _GM_xmlhttpRequest(a: GMApi, details: GMTypes.XHRDetails, requirePromise: boolean) { + static _GM_xmlhttpRequest( + a: GMApi, + details: GMTypes.XHRDetails, + requirePromise: boolean, + byPassConnect: boolean = false + ) { let reqDone = false; if (a.isInvalidContext()) { return { @@ -931,6 +936,7 @@ export default class GMApi extends GM_Base { password: details.password, redirect: details.redirect, fetch: details.fetch, + byPassConnect: byPassConnect, }; if (!param.headers) { param.headers = {}; @@ -975,11 +981,11 @@ export default class GMApi extends GM_Base { } } const xhrType = param.responseType; - const responseType = responseTypeOriginal; // 回傳用 + const responseType = responseTypeOriginal; // 回传用 // 发送信息 a.connect("GM_xmlhttpRequest", [param]).then((con) => { - // 注意。在此 callback 裡,不應直接存取 param, 否則會影響 GC + // 注意。在此 callback 里,不应直接存取 param, 否则会影响 GC connect = con; const resultTexts = [] as string[]; const resultBuffers = [] as Uint8Array[]; @@ -1247,9 +1253,9 @@ export default class GMApi extends GM_Base { case "onprogress": { if (details.onprogress) { if (!xhrType || xhrType === "text") { - responseText = false; // 設為false 表示需要更新。在 get setter 中更新 - response = false; // 設為false 表示需要更新。在 get setter 中更新 - responseXML = false; // 設為false 表示需要更新。在 get setter 中更新 + responseText = false; // 设为false 表示需要更新。在 get setter 中更新 + response = false; // 设为false 表示需要更新。在 get setter 中更新 + responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 } const res = { ...makeXHRCallbackParam(data), @@ -1270,9 +1276,9 @@ export default class GMApi extends GM_Base { controller = undefined; // GC用 } else if (resultType === 2) { // buffer type - responseText = false; // 設為false 表示需要更新。在 get setter 中更新 - response = false; // 設為false 表示需要更新。在 get setter 中更新 - responseXML = false; // 設為false 表示需要更新。在 get setter 中更新 + responseText = false; // 设为false 表示需要更新。在 get setter 中更新 + response = false; // 设为false 表示需要更新。在 get setter 中更新 + responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 /* if (xhrType === "blob") { const full = concatUint8(resultBuffers); @@ -1292,9 +1298,9 @@ export default class GMApi extends GM_Base { } else if (resultType === 3) { // string type - responseText = false; // 設為false 表示需要更新。在 get setter 中更新 - response = false; // 設為false 表示需要更新。在 get setter 中更新 - responseXML = false; // 設為false 表示需要更新。在 get setter 中更新 + responseText = false; // 设为false 表示需要更新。在 get setter 中更新 + response = false; // 设为false 表示需要更新。在 get setter 中更新 + responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 /* if (xhrType === "json") { const full = resultTexts.join(""); @@ -1305,7 +1311,7 @@ export default class GMApi extends GM_Base { } responseText = full; // XHR exposes responseText even for JSON } else if (xhrType === "document") { - // 不應該出現 document type + // 不应该出现 document type console.error("ScriptCat: Invalid Calling in GM_xmlhttpRequest"); responseText = ""; response = null; @@ -1403,70 +1409,211 @@ export default class GMApi extends GM_Base { 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" }) - GM_download(url: GMTypes.DownloadDetails | string, filename?: string): GMTypes.AbortHandle { - if (this.isInvalidContext()) { + static _GM_download(a: GMApi, details: GMTypes.DownloadDetails, requirePromise: boolean) { + if (a.isInvalidContext()) { return { + retPromise: requirePromise ? Promise.reject("GM_download: Invalid Context") : null, abort: () => {}, }; } - let details: GMTypes.DownloadDetails; - if (typeof url === "string") { - details = { - name: filename || "", - url, - }; - } else { - details = url; - } + 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; - 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": + let nativeAbort: (() => any) | null = null; + 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:")) { + 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; + connect.onMessage((data) => { + switch (data.action) { + case "onload": + details.onload && details.onload(data.data); + retPromiseResolve?.(data.data); + break; + case "onprogress": + details.onprogress && details.onprogress(data.data); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "ontimeout": + details.ontimeout && details.ontimeout(); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "onerror": + details.onerror && + details.onerror({ + error: "unknown", + }); + retPromiseReject?.(new Error("Unknown ERROR")); + break; + default: + LoggerCore.logger().warn("GM_download resp is error", { + data, + }); + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + break; + } + }); + } else { + // console.log("GM_download: Native Download Start"); + // native + const xhrParams = { + url: url, + responseType: "blob", + onloadend: async (res) => { + if (aborted) return; + // console.log("GM_download: Native Download End"); + if (res.response instanceof Blob) { + // console.log("GM_download: Chrome API Download Start"); + 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": + // console.log("GM_download: Chrome API Download End"); + details.onload && details.onload(data.data); + retPromiseResolve?.(data.data); + setTimeout(() => { + // 释放不需要的 URL + URL.revokeObjectURL(url); + }, 1); + break; + case "ontimeout": + details.ontimeout && details.ontimeout(); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "onerror": + details.onerror && + details.onerror({ + error: "unknown", + }); + 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 && details.onload({}); + }, + onprogress: (e) => { + details.onprogress && details.onprogress(e); + }, + ontimeout: () => { details.ontimeout && details.ontimeout(); - break; - case "onerror": + }, + onerror: () => { details.onerror && details.onerror({ error: "unknown", }); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - break; + }, + } as GMTypes.XHRDetails; + if (typeof details.headers === "object") { + xhrParams.headers = details.headers; } - }); - }); + // -- 兼容SC参数 -- + 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; + } + // -- 兼容SC参数 -- + const { retPromise, abort } = _GM_xmlhttpRequest(a, xhrParams, true, true); + retPromise?.catch(() => { + if (aborted) return; + retPromiseReject?.(new Error("Native Download ERROR")); + }); + nativeAbort = abort; + } + }; + handle().catch(console.error); return { + retPromise, abort: () => { + aborted = true; connect?.disconnect(); + nativeAbort?.(); }, }; } + // 用于脚本跨域请求,需要@connect domain指定允许的域名 + @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() + 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({ depend: ["GM_closeNotification", "GM_updateNotification"], alias: "GM.notification", @@ -1802,4 +1949,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_xmlhttpRequest, _GM_download } = GMApi; diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 98d3a08b3..5942263ad 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -1,16 +1,14 @@ 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 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 { cacheInstance } from "@App/app/cache"; -import EventEmitter from "eventemitter3"; import { type RuntimeService } from "./runtime"; import { getIcon, isFirefox, openInCurrentTab, cleanFileName, urlSanitize } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; @@ -269,16 +267,21 @@ export default class GMApi { // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); + let byPass = false; + if (data.api === "GM_xmlhttpRequest" && data.params?.[0]?.byPassConnect === true) byPass = true; const api = PermissionVerifyApiGet(data.api); if (!api) { throw new Error("gm api is not found"); } const req = await this.parseRequest(data); - try { - await this.permissionVerify.verify(req, api, sender); - } catch (e) { - this.logger.error("verify error", { api: data.api }, Logger.E(e)); - throw e; + if (!byPass && this.permissionVerify.noVerify(req, api, sender)) byPass = true; + if (!byPass) { + try { + await this.permissionVerify.verify(req, api, sender); + } catch (e) { + this.logger.error("verify error", { api: data.api }, Logger.E(e)); + throw e; + } } return api.api.call(this, req, sender); } @@ -1329,7 +1332,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"); } @@ -1345,133 +1348,61 @@ export default class GMApi { // 替换掉windows下文件名的非法字符为 - const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 - const startDownload = (blobURL: string, respond: any) => { - if (!blobURL) { - !isConnDisconnected && - msgConn.sendMessage({ - action: "onerror", - data: respond, - }); - throw new Error("GM_download ERROR: blobURL is not provided."); - } - chrome.downloads.download( - { - url: blobURL, - saveAs: params.saveAs, - filename: fileName, - }, - (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 (!isConnDisconnected) { - if (ok) { - msgConn.sendMessage({ - action: "onload", - data: respond, - }); - } else { - msgConn.sendMessage({ - action: "onerror", - data: respond, - }); - } - } - } - ); - }; - if (params.url.startsWith("blob:") || params.downloadMode === "browser") { - startDownload(params.url, null); - return; + const blobURL = params.url; + const respond = null; + if (!blobURL) { + !isConnDisconnected && + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); + throw new Error("GM_download ERROR: blobURL is not provided."); } - // 使用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, - }; - let msgToSend = null; - switch (data.action) { - case "onload": { - const response = xhr.response; - let url = ""; - if (response instanceof Blob) { - url = URL.createObjectURL(response); - } else if (typeof response === "string") { - url = response; + 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.download( + { + url: blobURL, + saveAs: params.saveAs, + filename: fileName, + }, + (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 (!isConnDisconnected) { + if (ok) { + msgConn.sendMessage({ + action: "onload", + data: respond, + }); + } else { + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); } - startDownload(url, respond); - break; } - case "onerror": - msgToSend = { - 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; // ?????? - msgToSend = { - action: "onprogress", - data: respond, - }; - break; - case "ontimeout": - msgToSend = { - action: "ontimeout", - }; - break; - case "onloadend": - msgToSend = { - action: "onloadend", - data: respond, - }; - break; - } - if (!isConnDisconnected && msgToSend) { - msgConn.sendMessage(msgToSend); } - }); - const ret = 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) ); - msgConn.onDisconnect(() => { - // To be implemented - }); - return ret; } @PermissionVerify.API() diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index 4c0b761f3..0a020c418 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -122,7 +122,6 @@ export default class PermissionVerify { if (api.param.default) { return true; } - if (this.noVerify(request, api, sender)) return true; // 没有其它条件,从metadata.grant中判断 const { grant } = request.script.metadata; if (!grant) { diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index 71557a284..8e7bf4043 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -530,7 +530,7 @@ declare namespace GMTypes { interface DownloadDetails { method?: "GET" | "POST"; downloadMode?: "native" | "browser"; - url: string; + url: string | File | Blob; name: string; headers?: { [key: string]: string }; saveAs?: boolean; diff --git a/src/types/main.d.ts b/src/types/main.d.ts index 708aed30d..b46a485d1 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -70,6 +70,7 @@ declare namespace GMSend { nocache?: boolean; dataType?: "FormData" | "Blob"; redirect?: "follow" | "error" | "manual"; + byPassConnect?: boolean; } interface XHRFormDataFile { diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 8f38a8a72..15227cc20 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; @@ -527,21 +527,26 @@ 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; + conflictAction?: "uniquify" | "overwrite" | "prompt"; + + // SC 标准参数 + method?: "GET" | "POST"; + downloadMode?: "native" | "browser"; timeout?: number; cookie?: string; anonymous?: boolean; - onerror?: Listener; - ontimeout?: () => void; + // TM/SC 标准回调 onload?: Listener; + onerror?: Listener; onprogress?: Listener; + ontimeout?: () => void; } interface NotificationThis extends NotificationDetails { From 86b15021ca91dea294d3b73612f23098863e5652 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:40:53 +0900 Subject: [PATCH 05/44] =?UTF-8?q?GM=5Fdownload:=20=E8=B7=9F=E9=9A=8FTM?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=BD=BF=E7=94=A8=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 8e60c730c..3a0bbac4a 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -1490,6 +1490,7 @@ export default class GMApi extends GM_Base { // 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; From 7e4f5cc7f20c5021b8cbfbcf8e8db6418658f5b4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:48:16 +0900 Subject: [PATCH 06/44] =?UTF-8?q?`GM=5Fdownload`=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 55 +++++++++++------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 5942263ad..910f4f42e 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -1370,39 +1370,32 @@ export default class GMApi { if (typeof params.conflictAction === "string") { downloadAPIOptions.conflictAction = params.conflictAction; } - chrome.downloads.download( - { - url: blobURL, - saveAs: params.saveAs, - filename: fileName, - }, - (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 (!isConnDisconnected) { - if (ok) { - msgConn.sendMessage({ - action: "onload", - data: respond, - }); - } else { - msgConn.sendMessage({ - action: "onerror", - data: respond, - }); - } + 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 (!isConnDisconnected) { + if (ok) { + msgConn.sendMessage({ + action: "onload", + data: respond, + }); + } else { + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); } } - ); + }); } @PermissionVerify.API() From e52d4be49db0097baee9323cd31c9a5a3ded2a6f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:29:13 +0900 Subject: [PATCH 07/44] =?UTF-8?q?`GM=5Fdownload`=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 65 ++++++++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 910f4f42e..b96b45596 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -1340,22 +1340,64 @@ export default class GMApi { if (!msgConn) { throw new Error("GM_download ERROR: msgConn is undefined"); } + let reqCompleteWith = ""; + let cDownloadId = 0; let isConnDisconnected = false; - msgConn.onDisconnect(() => { - isConnDisconnected = true; - }); const params = request.params[0]; // 替换掉windows下文件名的非法字符为 - const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 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.onChanged.removeListener(onChangedListener); + } else if (downloadDelta.state?.current === "interrupted") { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "interrupted"; + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); + } + 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) { - !isConnDisconnected && + 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 = { @@ -1370,6 +1412,7 @@ export default class GMApi { 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; @@ -1382,18 +1425,18 @@ export default class GMApi { console.error("GM_download ERROR: API Failure for chrome.downloads.download."); ok = false; } - if (!isConnDisconnected) { - if (ok) { - msgConn.sendMessage({ - action: "onload", - data: respond, - }); - } else { + if (ok) { + cDownloadId = downloadId as number; + } + if (!ok) { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "error:download_api_error"; msgConn.sendMessage({ action: "onerror", data: respond, }); } + chrome.downloads.onChanged.removeListener(onChangedListener); } }); } From c1ce6a81be307d845cf96864da0838c74fca9281 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:25:34 +0900 Subject: [PATCH 08/44] =?UTF-8?q?`GM=5FxmlhttpRequest`=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20`context`=EF=BC=9B`GM=5Fdownload`=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20`context`,=20`user`,=20`password`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api.ts | 265 ++++++++++++++++-------------- src/types/scriptcat.d.ts | 18 +- 2 files changed, 155 insertions(+), 128 deletions(-) diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 3a0bbac4a..17e3a35c2 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -38,6 +38,8 @@ export interface GMRequestHandle { abort: () => void; } +type ContextType = unknown; + type GMXHRResponseType = { DONE: number; HEADERS_RECEIVED: number; @@ -50,6 +52,7 @@ type GMXHRResponseType = { RESPONSE_TYPE_DOCUMENT: string; RESPONSE_TYPE_JSON: string; RESPONSE_TYPE_STREAM: string; + context?: ContextType; finalUrl: string; readyState: 0 | 1 | 4 | 2 | 3; status: number; @@ -921,6 +924,7 @@ export default class GMApi extends GM_Base { } } } + const contentContext = details.context; const param: GMSend.XHRDetails = { method: details.method, @@ -928,7 +932,6 @@ export default class GMApi extends GM_Base { url: "", headers: details.headers, cookie: details.cookie, - context: details.context, responseType: details.responseType, overrideMimeType: details.overrideMimeType, anonymous: details.anonymous, @@ -1040,8 +1043,9 @@ export default class GMApi extends GM_Base { statusText: "", }; } + let retParam; if (resError) { - return { + retParam = { DONE: 4, HEADERS_RECEIVED: 2, LOADING: 3, @@ -1056,107 +1060,111 @@ export default class GMApi extends GM_Base { toString: () => "[object Object]", // follow TM ...resError, } as GMXHRResponseType; - } - const param = { - 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 + } 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; } - 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; + } + 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 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); } - } - 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 - responseText = `${resultTexts.join("")}`; + 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 + responseText = `${resultTexts.join("")}`; + } } - } - return responseText as string; - }, - toString: () => "[object Object]", // follow TM - } as GMXHRResponseType; - if (res.error) { - param.error = res.error; + 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 (responseType === "json" && param.response === null) { - response = undefined; // TM不使用null,使用undefined + if (typeof contentContext !== "undefined") { + retParam.context = contentContext; } - return param; + return retParam; }; doAbort = (data: any) => { if (!reqDone) { @@ -1436,11 +1444,35 @@ export default class GMApi extends GM_Base { let aborted = false; let connect: MessageConnect; 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; + } + 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 + } + } const con = await a.connect("GM_download", [ { method: details.method, @@ -1459,22 +1491,19 @@ export default class GMApi extends GM_Base { connect.onMessage((data) => { switch (data.action) { case "onload": - details.onload && details.onload(data.data); + details.onload?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); break; case "onprogress": - details.onprogress && details.onprogress(data.data); + details.onprogress?.(makeCallbackParam({ ...data.data })); retPromiseReject?.(new Error("Timeout ERROR")); break; case "ontimeout": - details.ontimeout && details.ontimeout(); + details.ontimeout?.(makeCallbackParam({})); retPromiseReject?.(new Error("Timeout ERROR")); break; case "onerror": - details.onerror && - details.onerror({ - error: "unknown", - }); + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); retPromiseReject?.(new Error("Unknown ERROR")); break; default: @@ -1486,7 +1515,6 @@ export default class GMApi extends GM_Base { } }); } else { - // console.log("GM_download: Native Download Start"); // native const xhrParams = { url: url, @@ -1494,9 +1522,7 @@ export default class GMApi extends GM_Base { responseType: "blob", onloadend: async (res) => { if (aborted) return; - // console.log("GM_download: Native Download End"); if (res.response instanceof Blob) { - // console.log("GM_download: Chrome API Download Start"); const url = URL.createObjectURL(res.response); // 生命周期跟随当前 content/page 而非 offscreen const con = await a.connect("GM_download", [ { @@ -1516,8 +1542,7 @@ export default class GMApi extends GM_Base { connect.onMessage((data) => { switch (data.action) { case "onload": - // console.log("GM_download: Chrome API Download End"); - details.onload && details.onload(data.data); + details.onload?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); setTimeout(() => { // 释放不需要的 URL @@ -1525,14 +1550,11 @@ export default class GMApi extends GM_Base { }, 1); break; case "ontimeout": - details.ontimeout && details.ontimeout(); + details.ontimeout?.(makeCallbackParam({})); retPromiseReject?.(new Error("Timeout ERROR")); break; case "onerror": - details.onerror && - details.onerror({ - error: "unknown", - }); + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); retPromiseReject?.(new Error("Unknown ERROR")); break; default: @@ -1546,25 +1568,22 @@ export default class GMApi extends GM_Base { } }, onload: () => { - // details.onload && details.onload({}); + // details.onload?.(makeCallbackParam({})) }, onprogress: (e) => { - details.onprogress && details.onprogress(e); + details.onprogress?.(makeCallbackParam({ ...e })); }, ontimeout: () => { - details.ontimeout && details.ontimeout(); + details.ontimeout?.(makeCallbackParam({})); }, onerror: () => { - details.onerror && - details.onerror({ - error: "unknown", - }); + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); }, } as GMTypes.XHRDetails; if (typeof details.headers === "object") { xhrParams.headers = details.headers; } - // -- 兼容SC参数 -- + // -- 其他参数 -- if (typeof details.method === "string") { xhrParams.method = details.method || "GET"; } @@ -1577,7 +1596,11 @@ export default class GMApi extends GM_Base { if (typeof details.anonymous === "boolean") { xhrParams.anonymous = details.anonymous; } - // -- 兼容SC参数 -- + 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; diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 15227cc20..59f03c39b 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -535,18 +535,22 @@ declare namespace GMTypes { saveAs?: boolean; conflictAction?: "uniquify" | "overwrite" | "prompt"; - // SC 标准参数 - method?: "GET" | "POST"; - downloadMode?: "native" | "browser"; - timeout?: number; - cookie?: string; - anonymous?: boolean; + // 其他参数 + 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?: () => void; + ontimeout?: (arg1?: any) => void; } interface NotificationThis extends NotificationDetails { From dc9006593757ef19ffaa381747532009c9ecc93d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:10:51 +0900 Subject: [PATCH 09/44] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B0=83=E6=95=B4?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=8F=82=E8=80=83=E6=97=A0=E6=B3=95?= =?UTF-8?q?GC=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api.ts | 73 +++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 17e3a35c2..75dc3e974 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -1,4 +1,4 @@ -import type { Message, MessageConnect } from "@Packages/message/types"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { GMRegisterMenuCommandParam, @@ -990,9 +990,11 @@ export default class GMApi extends GM_Base { a.connect("GM_xmlhttpRequest", [param]).then((con) => { // 注意。在此 callback 里,不应直接存取 param, 否则会影响 GC connect = con; - const resultTexts = [] as string[]; - const resultBuffers = [] as Uint8Array[]; - let finalResultBuffers: Uint8Array | null = null; + 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 errorOccur: string | null = null; @@ -1007,7 +1009,16 @@ export default class GMApi extends GM_Base { } readerStream = undefined; - const makeXHRCallbackParam = ( + let refCleanup: (() => void) | null = () => { + // 清掉函数参考,避免各变数参考无法GC + makeXHRCallbackParam = null; + onMessageHandler = null; + doAbort = null; + refCleanup = null; + connect = null; + }; + + const makeXHRCallbackParam_ = ( res: { // finalUrl: string; @@ -1028,8 +1039,7 @@ export default class GMApi extends GM_Base { (typeof res.error === "string" && (res.status === 0 || res.status >= 300 || res.status < 200) && !res.statusText && - resultBuffers.length === 0 && - resultTexts.length === 0) || + isEmptyResult) || res.error === "aborted" ) { resError = { @@ -1116,6 +1126,10 @@ export default class GMApi extends GM_Base { break; } } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; + } } return response as string | ArrayBuffer | Blob | Document | ReadableStream | null; }, @@ -1147,7 +1161,12 @@ export default class GMApi extends GM_Base { responseText = text; } else { // resultType === 3 - responseText = `${resultTexts.join("")}`; + if (finalResultText === null) finalResultText = `${resultTexts.join("")}`; + responseText = finalResultText; + } + if (reqDone) { + resultTexts.length = 0; + resultBuffers.length = 0; } } return responseText as string; @@ -1166,15 +1185,18 @@ export default class GMApi extends GM_Base { } return retParam; }; + let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; doAbort = (data: any) => { if (!reqDone) { errorOccur = "AbortError"; - details.onabort?.(makeXHRCallbackParam(data)); + details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + refCleanup?.(); } + doAbort = null; }; - con.onMessage((msgData) => { + let onMessageHandler: ((data: TMessage) => void) | null = (msgData: TMessage) => { stackAsyncTask(asyncTaskId, async () => { const data = msgData.data as Record & { // @@ -1208,18 +1230,21 @@ export default class GMApi extends GM_Base { 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; @@ -1230,6 +1255,7 @@ export default class GMApi extends GM_Base { const d = msgData.data.chunk as string; const u8 = base64ToUint8(d); resultBuffers.push(u8); + isEmptyResult = false; resultType = 2; break; } @@ -1238,25 +1264,30 @@ export default class GMApi extends GM_Base { 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)); + details.onload?.(makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { reqDone = true; - const xhrReponse = makeXHRCallbackParam(data); + 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)); + details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -1266,7 +1297,7 @@ export default class GMApi extends GM_Base { responseXML = false; // 设为false 表示需要更新。在 get setter 中更新 } const res = { - ...makeXHRCallbackParam(data), + ...(makeXHRCallbackParam?.(data) ?? {}), lengthComputable: data.lengthComputable as boolean, loaded: data.loaded as number, total: data.total as number, @@ -1341,26 +1372,28 @@ export default class GMApi extends GM_Base { */ } } - details.onreadystatechange?.(makeXHRCallbackParam(data)); + details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; - details.ontimeout?.(makeXHRCallbackParam(data)); + 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); + details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); reqDone = true; + refCleanup?.(); } break; case "onabort": - doAbort(data); + doAbort?.(data); break; // case "onstream": // controller?.enqueue(new Uint8Array(data)); @@ -1372,7 +1405,9 @@ export default class GMApi extends GM_Base { break; } }); - }); + }; + + connect?.onMessage((msgData) => onMessageHandler?.(msgData)); }); }; // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 From c410be8e101b1e53250dc2bcb03fb4b03834c110 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:59:38 +0900 Subject: [PATCH 10/44] =?UTF-8?q?=E6=94=AF=E6=8C=81=20`binary:=20true`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/xhr_bg_core.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pkg/utils/xhr_bg_core.ts b/src/pkg/utils/xhr_bg_core.ts index 2a754619d..13a4b4342 100644 --- a/src/pkg/utils/xhr_bg_core.ts +++ b/src/pkg/utils/xhr_bg_core.ts @@ -874,6 +874,11 @@ context a property which will be added to the response object } } + 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); }; From bb5c42894c136d2c7abda4d2af09e67d533487a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 5 Nov 2025 17:29:48 +0800 Subject: [PATCH 11/44] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chrome-extension-mock/web_reqeuest.ts | 31 ------------------- src/app/service/service_worker/gm_api.test.ts | 15 ++++----- tests/runtime/gm_api.test.ts | 16 ++++++++++ 3 files changed, 24 insertions(+), 38 deletions(-) diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index 67afd403a..2df2e3f92 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -3,37 +3,6 @@ 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; diff --git a/src/app/service/service_worker/gm_api.test.ts b/src/app/service/service_worker/gm_api.test.ts index 82b4083ec..efc9bdb76 100644 --- a/src/app/service/service_worker/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api.test.ts @@ -55,14 +55,15 @@ describe.concurrent("isConnectMatched", () => { () => { const req = new URL("https://example.com/path"); - // 无 url - const senderNoUrl = makeSender(); - expect(getConnectMatched(["self"], req, senderNoUrl)).toBe(ConnectMatch.NONE); + // 无 url + const senderNoUrl = makeSender(); + expect(getConnectMatched(["self"], req, senderNoUrl)).toBe(ConnectMatch.NONE); - // 无效 URL(try/catch 会吞掉错误) - const senderBadUrl = makeSender("not a valid url"); - expect(getConnectMatched(["self"], req, senderBadUrl)).toBe(ConnectMatch.NONE); - }); + // 无效 URL(try/catch 会吞掉错误) + const senderBadUrl = makeSender("not a valid url"); + expect(getConnectMatched(["self"], req, senderBadUrl)).toBe(ConnectMatch.NONE); + } + ); it.concurrent('当 "self" 不符合但尾缀规则符合时仍应回传 true(走到后续条件)', () => { const req = new URL("https://api.example.com/data"); diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index cda3c0df2..4505d6883 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -66,6 +66,8 @@ beforeAll(async () => { 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) { @@ -406,4 +408,18 @@ describe("GM xmlHttpRequest", () => { }); }); }); + it.concurrent("404", async () => { + await new Promise((resolve) => { + customResponse.enabled = false; + 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(); + }, + }); + }); + }); }); From 5dc7473109353eef5de84c3894a893112192a839 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:39:36 +0900 Subject: [PATCH 12/44] =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=88=90=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E8=B5=8B=E4=BA=88=E5=90=AB=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 575512810..4510f22e9 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -64,6 +64,10 @@ type TXhrReqObject = { startTime: number; }; +const enum xhrExtraCode { + DOMAIN_NOT_INCLUDED = 0x30, +} + const xhrReqEntries = new Map(); const setReqDone = (stdUrl: string, xhrReqEntry: TXhrReqObject) => { @@ -818,7 +822,7 @@ export default class GMApi { return true; } if (!askUnlistedConnect && request.script.metadata.connect?.find((e) => !!e)) { - request.extraCode = 0x30; + request.extraCode = xhrExtraCode.DOMAIN_NOT_INCLUDED; return false; } } @@ -909,7 +913,7 @@ export default class GMApi { if (!param1) { throw throwErrorFn("param is failed"); } - if (request.extraCode === 0x30) { + 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' // 'Refused to connect to "https://example.org/": URL is blacklisted' const msg = `Refused to connect to "${param1.url}": This domain is not a part of the @connect list`; From 266cb5bf3e6f042718a30036ae9f1c21a94cbc4e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:40:08 +0900 Subject: [PATCH 13/44] =?UTF-8?q?=E4=B8=8D=E7=94=A8=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 4510f22e9..0ec39028e 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -1555,30 +1555,6 @@ export default class GMApi { } ); - // chrome.declarativeNetRequest.updateSessionRules({ - // removeRuleIds: [9001], - // addRules: [ - // { - // id: 9001, - // action: { - // type: "modifyHeaders", - // requestHeaders: [ - // { - // header: "X-SC-Request-Marker", - // operation: "remove", - // }, - // ], - // }, - // priority: 1, - // condition: { - // resourceTypes: ["xmlhttprequest"], - // // 不要指定 requestMethods。 这个DNR是对所有后台发出的xhr请求, 即使它是 HEAD,DELETE,也要捕捉 - // tabIds: [chrome.tabs.TAB_ID_NONE], // 只限于后台 service_worker / offscreen - // }, - // }, - // ], - // }); - chrome.webRequest.onBeforeRedirect.addListener( (details) => { const lastError = chrome.runtime.lastError; From 48279049977224209594fc18e3b44502bc0965df Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:42:37 +0900 Subject: [PATCH 14/44] =?UTF-8?q?=E6=B2=A1=E5=9C=B0=E6=96=B9=E5=BC=95?= =?UTF-8?q?=E7=94=A8dealXhrResponse=E4=BA=86=EF=BC=8C=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/offscreen/gm_api.ts | 84 ----------------------------- 1 file changed, 84 deletions(-) diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts index 37c234dee..f990b3406 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -1,93 +1,9 @@ -import LoggerCore from "@App/app/logger/core"; -import Logger from "@App/app/logger/logger"; import type { IGetSender, Group } from "@Packages/message/server"; -import type { MessageConnect } from "@Packages/message/types"; import { bgXhrInterface } from "../service_worker/xhr_interface"; 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) { const con = sender.getConnect(); // con can be undefined if (!con) throw new Error("offscreen xmlHttpRequest: Connection is undefined"); From f7fbcebcce52b3f3ebd917b5ffe7f5f1429b1a81 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:48:40 +0900 Subject: [PATCH 15/44] =?UTF-8?q?=E4=BF=AE=E6=AD=A3response=E7=A9=BA?= =?UTF-8?q?=E5=80=BC=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 75dc3e974..624b06e3a 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -996,6 +996,7 @@ export default class GMApi extends GM_Base { 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; @@ -1309,7 +1310,11 @@ export default class GMApi extends GM_Base { break; } case "onreadystatechange": { - if (data.readyState === 4 && data.ok) { + // 避免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用 From 91ce95b5a5e3eb7d03fb2b258e88000435e25adc Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:19:04 +0900 Subject: [PATCH 16/44] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20GM=20xhr=20API=20?= =?UTF-8?q?=E9=BB=91=E5=90=8D=E5=8D=95=E5=88=A4=E6=96=AD=20=EF=BC=88?= =?UTF-8?q?=E4=B8=8ETM=E4=B8=80=E8=87=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 40 +++++++++++++++++-- .../service_worker/permission_verify.ts | 16 +++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 0ec39028e..a7ff8fe4c 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -65,7 +65,9 @@ type TXhrReqObject = { }; const enum xhrExtraCode { + INVALID_URL = 0x20, DOMAIN_NOT_INCLUDED = 0x30, + DOMAIN_IN_BLACKLIST = 0x40, } const xhrReqEntries = new Map(); @@ -120,6 +122,7 @@ type OnHeadersReceivedOptions = `${chrome.webRequest.OnHeadersReceivedOptions}`; // 为了支持外部依赖注入,方便测试和扩展 interface IGMExternalDependencies { emitEventToTab(to: ExtMessageSender, req: EmitEventRequest): void; + isBlacklistNetwork(url: URL): boolean; } /** @@ -230,6 +233,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 { @@ -237,6 +247,9 @@ 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([ @@ -281,7 +294,7 @@ export default class GMApi { if (!byPass && this.permissionVerify.noVerify(req, api, sender)) byPass = true; if (!byPass) { 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; @@ -809,9 +822,19 @@ export default class GMApi { // } @PermissionVerify.API({ - confirm: async (request: GMApiRequest<[GMSend.XHRDetails]>, sender: IGetSender) => { + confirm: async (request: GMApiRequest<[GMSend.XHRDetails]>, sender: IGetSender, GMApiInstance: GMApi) => { const config = request.params[0]; - const url = new URL(config.url); + let url; + try { + url = new URL(config.url); + } catch { + request.extraCode = xhrExtraCode.INVALID_URL; + return false; + } + if (GMApiInstance.gmExternalDependencies.isBlacklistNetwork(url)) { + request.extraCode = xhrExtraCode.DOMAIN_IN_BLACKLIST; + return false; + } const connectMatched = getConnectMatched(request.script.metadata.connect, url, sender); if (connectMatched === 1) { if (!askConnectStar) { @@ -913,12 +936,21 @@ export default class GMApi { if (!param1) { throw throwErrorFn("param is failed"); } + + if (request.extraCode === xhrExtraCode.INVALID_URL) { + const msg = `Refused to connect to "${param1.url}": The url is invalid`; + throw throwErrorFn(msg); + } 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' - // 'Refused to connect to "https://example.org/": URL is blacklisted' const msg = `Refused to connect to "${param1.url}": This domain is not a part of the @connect list`; throw throwErrorFn(msg); } + if (request.extraCode === xhrExtraCode.DOMAIN_IN_BLACKLIST) { + // 'Refused to connect to "https://example.org/": URL is blacklisted' + const msg = `Refused to connect to "${param1.url}": URL is blacklisted`; + throw throwErrorFn(msg); + } try { // 先处理unsafe hearder diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index 0a020c418..7cc5b2540 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"; 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 { // 默认提供的函数 @@ -117,7 +122,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; @@ -139,7 +144,7 @@ export default class PermissionVerify { ) { // 需要用户确认 if (confirm) { - return await this.pushConfirmQueue(request, confirm, sender); + return await this.pushConfirmQueue(request, confirm, sender, GMApiInstance); } return true; } @@ -167,9 +172,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; } From 1e02ab85a83a7ac52af88253ebb4f9f230ad5dff Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:19:38 +0900 Subject: [PATCH 17/44] =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=9B=B4=E6=94=B9=E6=88=90=20concurrent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/runtime/gm_api.test.ts | 106 ++++++++++++++++------------------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index 4505d6883..a26c61014 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -6,11 +6,13 @@ import { afterAll, beforeAll, describe, expect, it, vi, vitest } from "vitest"; import { addTestPermission, initTestGMApi } from "@Tests/utils"; import { setMockNetworkResponse } from "@Tests/shared"; -const customResponse = { - enabled: false, - responseHeaders: {}, - responseContent: null, -} as Record; +const customXhrResponseMap = new Map< + string, + { + responseHeaders: Record; + responseContent: any; + } +>(); const realXMLHttpRequest = global.XMLHttpRequest; @@ -34,11 +36,11 @@ const script: Script = { }; beforeAll(async () => { - customResponse.enabled = false; await new ScriptDAO().save(script); const { mockXhr } = mockNetwork({ onSend: async (request) => { - if (customResponse.enabled) { + const customResponse = customXhrResponseMap.get(request.url); + if (customResponse) { return request.respond(200, customResponse.responseHeaders, customResponse.responseContent); } switch (request.url) { @@ -93,10 +95,9 @@ beforeAll(async () => { afterAll(() => { vi.stubGlobal("XMLHttpRequest", realXMLHttpRequest); - customResponse.enabled = false; }); -describe("测试GMApi环境 - XHR", async () => { +describe.concurrent("测试GMApi环境 - XHR", async () => { const msg = initTestGMApi(); const script: Script = { uuid: randomUUID(), @@ -122,14 +123,16 @@ describe("测试GMApi环境 - XHR", async () => { const gmApi = new GMApi("serviceWorker", msg, { uuid: script.uuid, }); - it("test GM xhr - plain text", async () => { - customResponse.enabled = true; - customResponse.responseHeaders = {}; - customResponse.responseContent = "example"; + 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: "https://mock-xmlhttprequest.test/", + url: testUrl, onload: (res) => { resolve(true); onload(res.responseText); @@ -142,17 +145,17 @@ describe("测试GMApi环境 - XHR", async () => { expect(onload).toBeCalled(); expect(onload.mock.calls[0][0]).toBe("example"); }); - it("test GM xhr - plain text [fetch]", async () => { - console.log(100, "测试GMApi环境 - XHR > test GM xhr - plain text [fetch]"); - setMockNetworkResponse("https://mock-xmlhttprequest.test/", { - data: "Response for GET https://mock-xmlhttprequest.test/", + 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: "https://mock-xmlhttprequest.test/", + url: testUrl, onload: (res) => { resolve(true); onload(res.responseText); @@ -163,10 +166,9 @@ describe("测试GMApi环境 - XHR", async () => { }); }); expect(onload).toBeCalled(); - expect(onload.mock.calls[0][0]).toBe("Response for GET https://mock-xmlhttprequest.test/"); + expect(onload.mock.calls[0][0]).toBe("Response for GET https://mock-xmlhttprequest-plain-fetch.test/"); }); - it("test GM xhr - blob", async () => { - console.log(100, "test GM xhr - blob"); + it.concurrent("test GM xhr - blob", async () => { // Define a simple HTML page as a string const htmlContent = ` @@ -183,18 +185,20 @@ describe("测试GMApi环境 - XHR", async () => { // Create a Blob object from the HTML string const blob = new Blob([htmlContent], { type: "text/html" }); - customResponse.enabled = true; - customResponse.responseHeaders = {}; - customResponse.responseContent = blob; + + 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: "https://mock-xmlhttprequest.test/", + url: testUrl, responseType: "blob", onload: (res) => { - customResponse.responseContent = ""; onload(res); // if (!(res.response instanceof Blob)) { // resolve(false); @@ -207,18 +211,18 @@ describe("测试GMApi环境 - XHR", async () => { // }); }, onloadend: () => { - customResponse.responseContent = ""; 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("test GM xhr - blob [fetch]", async () => { + it.concurrent("test GM xhr - blob [fetch]", async () => { // Define a simple HTML page as a string const htmlContent = ` @@ -269,46 +273,44 @@ describe("测试GMApi环境 - XHR", async () => { expect(fn2.mock.calls[0][0]).not.toBe(blob); }); - it("test GM xhr - json", async () => { + 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); - customResponse.enabled = true; - customResponse.responseHeaders = { "Content-Type": "application/json" }; - customResponse.responseContent = jsonObjStr; + + 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: "https://mock-xmlhttprequest.test/", + url: testUrl, responseType: "json", onload: (res) => { - customResponse.enabled = true; - customResponse.responseHeaders = {}; - customResponse.responseContent = ""; resolve(true); fn1(res.responseText); fn2(res.response); }, onloadend: () => { - customResponse.enabled = true; - customResponse.responseHeaders = {}; - customResponse.responseContent = ""; 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("test GM xhr - json [fetch]", async () => { + 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); - - setMockNetworkResponse("https://mock-xmlhttprequest.test/", { + const testUrl = "https://mock-xmlhttprequest-json-fetch.test/"; + setMockNetworkResponse(testUrl, { data: jsonObjStr, contentType: "application/json", }); @@ -317,38 +319,32 @@ describe("测试GMApi环境 - XHR", async () => { await new Promise((resolve) => { gmApi.GM_xmlhttpRequest({ fetch: true, - url: "https://mock-xmlhttprequest.test/", + url: testUrl, responseType: "json", onload: (res) => { - customResponse.enabled = true; - customResponse.responseHeaders = {}; - customResponse.responseContent = ""; resolve(true); fn1(res.responseText); fn2(res.response); }, onloadend: () => { - customResponse.enabled = true; - customResponse.responseHeaders = {}; - customResponse.responseContent = ""; resolve(false); }, }); }); + customXhrResponseMap.delete(testUrl); expect(fn1).toBeCalled(); expect(fn1.mock.calls[0][0]).toBe(jsonObjStr); expect(fn2.mock.calls[0][0]).toStrictEqual(jsonObj); }); }); -describe("GM xmlHttpRequest", () => { +describe.concurrent("GM xmlHttpRequest", () => { const msg = initTestGMApi(); const gmApi = new GMApi("serviceWorker", msg, { uuid: script.uuid, }); - it("get", () => { + it.concurrent("get", () => { return new Promise((resolve) => { - customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com", onreadystatechange: (resp) => { @@ -364,7 +360,6 @@ describe("GM xmlHttpRequest", () => { // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 it.concurrent("json", async () => { await new Promise((resolve) => { - customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://example.com/json", method: "GET", @@ -379,7 +374,6 @@ describe("GM xmlHttpRequest", () => { }); // bad json await new Promise((resolve) => { - customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com/", method: "GET", @@ -394,7 +388,6 @@ describe("GM xmlHttpRequest", () => { }); it.concurrent("header", async () => { await new Promise((resolve) => { - customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com/header", method: "GET", @@ -410,7 +403,6 @@ describe("GM xmlHttpRequest", () => { }); it.concurrent("404", async () => { await new Promise((resolve) => { - customResponse.enabled = false; gmApi.GM_xmlhttpRequest({ url: "https://www.example.com/notexist", method: "GET", From 57eab2a0c5a599f281b1b9819013c08560a971d2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:27:53 +0900 Subject: [PATCH 18/44] =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/runtime/gm_api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index a26c61014..88a26ce40 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -142,6 +142,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }, }); }); + customXhrResponseMap.delete(testUrl); expect(onload).toBeCalled(); expect(onload.mock.calls[0][0]).toBe("example"); }); @@ -331,7 +332,6 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }, }); }); - customXhrResponseMap.delete(testUrl); expect(fn1).toBeCalled(); expect(fn1.mock.calls[0][0]).toBe(jsonObjStr); expect(fn2.mock.calls[0][0]).toStrictEqual(jsonObj); From 3677003a6a9277bb1b5e437f292154aad19001a4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 8 Nov 2025 00:39:32 +0900 Subject: [PATCH 19/44] =?UTF-8?q?FF=E5=85=BC=E5=AE=B9=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index a7ff8fe4c..2deaaa565 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -1610,10 +1610,10 @@ export default class GMApi { ); const reqOpt: OnBeforeSendHeadersOptions[] = ["requestHeaders"]; const respOpt: OnHeadersReceivedOptions[] = ["responseHeaders"]; - // if (!isFirefox()) { - reqOpt.push("extraHeaders"); - respOpt.push("extraHeaders"); - // } + if (!isFirefox()) { + reqOpt.push("extraHeaders"); + respOpt.push("extraHeaders"); + } chrome.webRequest.onErrorOccurred.addListener( (details) => { From 0739574e6857b50d136267d70037cfd04e50c892 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 8 Nov 2025 03:05:57 +0900 Subject: [PATCH 20/44] =?UTF-8?q?FF=E5=85=BC=E5=AE=B9=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 102 ++++++++++++++++------- 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 2deaaa565..eefa7384d 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -1059,38 +1059,78 @@ export default class GMApi { }, msgConn ); - return; + } else if (typeof XMLHttpRequest === "function") { + // No offscreen in Firefox, but Firefox background script itself provides XMLHttpRequest. + // Firefox 中没有 offscreen,但 Firefox 的"后台脚本"本身提供了 XMLHttpRequest。 + bgXhrInterface( + param1, + { + get finalUrl() { + return resultParam.finalUrl; + }, + get responseHeaders() { + return resultParam.responseHeaders; + }, + get status() { + return resultParam.statusCode; + }, + loadendCleanUp() { + loadendCleanUp(); + }, + async 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; + }> + ) { + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { + resultParamStatusCode = msg.data.status; + } + }, + }, + msgConn + ); + } else { + // 再发送到offscreen, 处理请求 + const offscreenCon = await connect(this.msgSender, "offscreen/gmApi/xmlHttpRequest", param1); + offscreenCon.onMessage((msg) => { + // 发送到content + let data = msg.data; + // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) + if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { + 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(); + }); } - // 再发送到offscreen, 处理请求 - const offscreenCon = await connect(this.msgSender, "offscreen/gmApi/xmlHttpRequest", param1); - offscreenCon.onMessage((msg) => { - // 发送到content - let data = msg.data; - // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) - if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { - 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(); - }); }; // stackAsyncTask 是为了 chrome.webRequest.onBeforeRequest 能捕捉当前 XHR 的 id From 6b19a4a367b11fb762001a50e133cc4b919ed028 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 9 Nov 2025 00:07:39 +0900 Subject: [PATCH 21/44] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=B8=85=E7=90=86?= =?UTF-8?q?=E4=B8=80=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api.ts | 122 ++--------------------- src/pkg/utils/xhr_bg_core.ts | 37 +++---- 2 files changed, 24 insertions(+), 135 deletions(-) diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index eefa7384d..c2ae56f01 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -713,114 +713,6 @@ export default class GMApi { return true; } - // 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.responseHeaders || respond.responseHeaders; - // } - // return respond; - // } - - // 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; - // } - // const reader = resp.body?.getReader(); - // if (!reader) { - // throw new Error("read is not found"); - // } - // const readData = ({ done, value }: { done: boolean; value?: Uint8Array }) => { - // if (done) { - // const data = this.dealFetch(config, resp, 4, resultParam); - // data.responseHeaders = resultParam.responseHeaders || 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); - // } - // }; - // reader.read().then(readData); - // send.responseHeaders = resultParam.responseHeaders || 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, GMApiInstance: GMApi) => { const config = request.params[0]; @@ -952,11 +844,19 @@ export default class GMApi { throw throwErrorFn(msg); } try { - // 先处理unsafe hearder + /* + 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 (!param1.cookiePartition || typeof param1.cookiePartition !== "object") { param1.cookiePartition = {}; } @@ -965,7 +865,7 @@ export default class GMApi { param1.cookiePartition.topLevelSite = undefined; } - // 添加请求header + // 添加请求header, 处理unsafe hearder await this.buildDNRRule(markerID, param1, sender); // let finalUrl = ""; // 等待response @@ -997,8 +897,6 @@ export default class GMApi { const f = async () => { if (useFetch) { // 只有fetch支持ReadableStream、redirect这些,直接使用fetch - // return this.CAT_fetch(param1, sender, resultParam); - bgXhrInterface( param1, { diff --git a/src/pkg/utils/xhr_bg_core.ts b/src/pkg/utils/xhr_bg_core.ts index 13a4b4342..20625bc29 100644 --- a/src/pkg/utils/xhr_bg_core.ts +++ b/src/pkg/utils/xhr_bg_core.ts @@ -1,5 +1,3 @@ -// console.log('streaming ' + (GM_xmlhttpRequest.RESPONSE_TYPE_STREAM === 'stream' ? 'supported' : 'not supported'); - import { dataDecode } from "./xhr_data"; /** @@ -21,8 +19,8 @@ import { dataDecode } from "./xhr_data"; * | `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` | (v5.2+) Cookie partition key. | - * | `topLevelSite` | `string` | Top frame site for partitioned cookies. | + * | `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. | @@ -324,7 +322,7 @@ export class FetchXHR { } try { - const opts = { + const opts: RequestInit = { method: this.method, headers: this.headers, body: this.body, @@ -629,22 +627,6 @@ export class FetchXHR { * @param settings Control */ export const bgXhrRequestFn = async (details: XmlhttpRequestFnDetails, settings: any) => { - /* - - - -cookie a cookie to be patched into the sent cookie set -cookiePartition v5.2+ object?, containing the partition key to be used for sent and received partitioned cookies -topLevelSite string?, representing the top frame site for partitioned cookies - - binary send the data string in binary mode -nocache don't cache the resource -revalidate revalidate maybe cached content - - -context a property which will be added to the response object - -*/ details.data = dataDecode(details.data as any); if (details.data === undefined) delete details.data; @@ -659,7 +641,8 @@ context a property which will be added to the response object let xhrResponseType: "arraybuffer" | "text" | "" = ""; const useFetch = isFetch || !!redirect || anonymous || isBufferStream; - // console.log("useFetch", isFetch, !!redirect, anonymous, isBufferStream); + + const isNoCache = !!details.nocache; const prepareXHR = async () => { let rawData = (details.data = await details.data); @@ -668,7 +651,7 @@ context a property which will be added to the response object const baseXHR = useFetch ? new FetchXHR({ - extraOptsFn: (opts: Record) => { + extraOptsFn: (opts: RequestInit) => { if (redirect) { opts.redirect = redirect; } @@ -676,6 +659,12 @@ context a property which will be added to the response object 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"; + } }, isBufferStream, onDataReceived: settings.onDataReceived, @@ -803,6 +792,8 @@ context a property which will be added to the response object } } + // 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) // From ae6b382d07dae679ee758644a3b3bb84ee3fafa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 12 Nov 2025 11:29:38 +0800 Subject: [PATCH 22/44] =?UTF-8?q?=E6=95=B4=E7=90=86=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/mocks/blob.ts | 118 +++++ tests/mocks/fetch.ts | 32 ++ .../network-mock.ts => tests/mocks/network.ts | 0 tests/mocks/request.ts | 145 ++++++ tests/mocks/response.ts | 125 +++++ tests/runtime/gm_api.test.ts | 4 +- tests/shared.ts | 9 - tests/utils.ts | 19 - tests/vitest.setup.ts | 439 +----------------- 9 files changed, 432 insertions(+), 459 deletions(-) create mode 100644 tests/mocks/blob.ts create mode 100644 tests/mocks/fetch.ts rename packages/network-mock.ts => tests/mocks/network.ts (100%) create mode 100644 tests/mocks/request.ts create mode 100644 tests/mocks/response.ts delete mode 100644 tests/shared.ts 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/packages/network-mock.ts b/tests/mocks/network.ts similarity index 100% rename from packages/network-mock.ts rename to tests/mocks/network.ts 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 88a26ce40..bc603312a 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -1,10 +1,10 @@ import { type Script, ScriptDAO, type ScriptRunResource } from "@App/app/repo/scripts"; import GMApi from "@App/app/service/content/gm_api"; -import { mockNetwork } from "@Packages/network-mock"; import { randomUUID } from "crypto"; import { afterAll, beforeAll, describe, expect, it, vi, vitest } from "vitest"; import { addTestPermission, initTestGMApi } from "@Tests/utils"; -import { setMockNetworkResponse } from "@Tests/shared"; +import { mockNetwork } from "@Tests/mocks/network"; +import { setMockNetworkResponse } from "@Tests/mocks/response"; const customXhrResponseMap = new Map< string, diff --git a/tests/shared.ts b/tests/shared.ts deleted file mode 100644 index 199f187b3..000000000 --- a/tests/shared.ts +++ /dev/null @@ -1,9 +0,0 @@ -const mockNetworkResponses = new Map(); - -export const setMockNetworkResponse = (url: string, v: any) => { - mockNetworkResponses.set(url, v); -}; - -export const getMockNetworkResponse = (url: string) => { - return mockNetworkResponses.get(url); -}; diff --git a/tests/utils.ts b/tests/utils.ts index aa3410dc2..2d07c328e 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -20,25 +20,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", diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index cc9b5d2a7..2027a6341 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -1,9 +1,11 @@ import chromeMock from "@Packages/chrome-extension-mock"; import { initTestEnv } from "./utils"; import "@testing-library/jest-dom/vitest"; -import { beforeAll, afterAll, vi } from "vitest"; -import { getMockNetworkResponse } from "./shared"; -import { setNetworkRequestCounter } from "@Packages/network-mock"; +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(); @@ -120,431 +122,10 @@ vi.stubGlobal("sandboxTestValue2", "sandboxTestValue2"); vi.stubGlobal("ttest1", 1); vi.stubGlobal("ttest2", 2); -// ---------------------------------------- Blob ------------------------------------------- -// Keep originals to restore later -const realFetch = globalThis.fetch; -const realRequest = globalThis.Request; -const realResponse = globalThis.Response; -const RealBlob = globalThis.Blob; +// Install globals +vi.stubGlobal("fetch", mockFetch); +vi.stubGlobal("Request", MockRequest); +vi.stubGlobal("Response", MockResponse); +vi.stubGlobal("Blob", MockBlob); -// --- 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; -} - -beforeAll(() => { - // --- Mock Blob --- - const BaseBlob: typeof Blob = - RealBlob ?? - 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(); - }, - }); - } - }; - - const mockBlobByteMap = new WeakMap(); - const getMockBlobBytes = (x: MockBlob) => { - return mockBlobByteMap.get(x).slice(); // Return a copy to prevent mutation - }; - 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)); - // @ts-expect-error - return new MockBlob([slicedData], { type: contentType ?? this.#type }); - } - - // @ts-expect-error - 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(); - }, - }); - } - // @ts-expect-error - async bytes(): Promise { - if (this.#isConsumed) throw new TypeError("Blob stream already consumed"); - return this.#data.slice(); - } - } - - // --- Mock Request --- - 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, - }); - } - } - - // --- Mock Response --- - 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, - }); - } - } - - // --- Mock Fetch --- - 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; - }); - - // Install globals - vi.stubGlobal("fetch", mockFetch); - vi.stubGlobal("Request", MockRequest); - vi.stubGlobal("Response", MockResponse); - vi.stubGlobal("Blob", MockBlob); -}); - -afterAll(() => { - // Restore originals - vi.stubGlobal("fetch", realFetch); - vi.stubGlobal("Request", realRequest); - vi.stubGlobal("Response", realResponse); - vi.stubGlobal("Blob", RealBlob ?? undefined); -}); vi.stubGlobal("define", "特殊关键字不能穿透沙盒"); From d19bc6c6e8d83b0a46065adfeebc033acb66667d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 12 Nov 2025 11:58:41 +0800 Subject: [PATCH 23/44] =?UTF-8?q?=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 6 +- src/app/service/content/exec_script.ts | 4 +- .../service/content/{ => gm}/gm_api.test.ts | 8 +- src/app/service/content/{ => gm}/gm_api.ts | 673 +----------------- .../service/content/{ => gm}/gm_context.ts | 2 +- src/app/service/content/{ => gm}/gm_info.ts | 4 +- src/app/service/content/gm/gm_xhr.ts | 628 ++++++++++++++++ src/app/service/offscreen/gm_api.ts | 2 +- .../{ => gm_api}/gm_api.test.ts | 0 .../service_worker/{ => gm_api}/gm_api.ts | 16 +- .../{ => gm_api}/xhr_interface.ts | 4 +- src/app/service/service_worker/resource.ts | 2 +- src/app/service/service_worker/runtime.ts | 2 +- .../utils/{utils_datatype.ts => datatype.ts} | 0 src/pkg/utils/{ => xhr}/xhr_bg_core.ts | 0 src/pkg/utils/{ => xhr}/xhr_data.ts | 2 +- 16 files changed, 665 insertions(+), 688 deletions(-) rename src/app/service/content/{ => gm}/gm_api.test.ts (99%) rename src/app/service/content/{ => gm}/gm_api.ts (63%) rename src/app/service/content/{ => gm}/gm_context.ts (96%) rename src/app/service/content/{ => gm}/gm_info.ts (92%) create mode 100644 src/app/service/content/gm/gm_xhr.ts rename src/app/service/service_worker/{ => gm_api}/gm_api.test.ts (100%) rename src/app/service/service_worker/{ => gm_api}/gm_api.ts (99%) rename src/app/service/service_worker/{ => gm_api}/xhr_interface.ts (96%) rename src/pkg/utils/{utils_datatype.ts => datatype.ts} (100%) rename src/pkg/utils/{ => xhr}/xhr_bg_core.ts (100%) rename src/pkg/utils/{ => xhr}/xhr_data.ts (99%) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index a4c96bec9..06216ce91 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/gm_context"; +import { protect } from "./gm/gm_context"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; +import { createGMBase } from "./gm/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..8cfdaad60 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/gm_info"; +import type { IGM_Base } from "./gm/gm_api"; // 执行脚本,控制脚本执行与停止 export default class ExecScript { diff --git a/src/app/service/content/gm_api.test.ts b/src/app/service/content/gm/gm_api.test.ts similarity index 99% rename from src/app/service/content/gm_api.test.ts rename to src/app/service/content/gm/gm_api.test.ts index 05e8b8f24..64c1a26f4 100644 --- a/src/app/service/content/gm_api.test.ts +++ b/src/app/service/content/gm/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/gm_api.ts similarity index 63% rename from src/app/service/content/gm_api.ts rename to src/app/service/content/gm/gm_api.ts index 624b06e3a..9f8328d7c 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm/gm_api.ts @@ -1,4 +1,4 @@ -import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; +import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { GMRegisterMenuCommandParam, @@ -8,22 +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 { base64ToUint8, concatUint8 } from "@App/pkg/utils/utils_datatype"; -import { stackAsyncTask } from "@App/pkg/utils/async_queue"; -import { dataEncode } from "@App/pkg/utils/xhr_data"; +import type { ContextType } from "./gm_xhr"; +import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; // 内部函数呼叫定义 export interface IGM_Base { @@ -38,36 +37,6 @@ export interface GMRequestHandle { abort: () => void; } -type ContextType = unknown; - -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; -}; - -type GMXHRResponseTypeWithError = GMXHRResponseType & Required>; - const integrity = {}; // 仅防止非法实例化 let valChangeCounterId = 0; @@ -85,78 +54,6 @@ const execEnvInit = (execEnv: GMApi) => { } }; -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 */ -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); - }); -}; - -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; -}; - -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; -}; - -// const urlToDocumentLocal = async (a: GMApi, url: string) => { -// if (typeof XMLHttpRequest === "undefined") return urlToDocumentInContentPage(a, url); -// return new Promise((resolve) => { -// const xhr = new XMLHttpRequest(); -// xhr.responseType = "document"; -// xhr.open("GET", url); -// xhr.onload = () => { -// const doc = xhr.response instanceof Document ? xhr.response : null; -// resolve(doc); -// }; -// xhr.send(); -// }); -// }; - -// const strToDocument = async (a: GMApi, text: string, contentType: DOMParserSupportedType) => { -// if (typeof DOMParser === "function") { -// // 前台环境(CONTENT/MAIN) -// // str -> Document (CONTENT/MAIN) -// // Document物件是在API呼叫环境产生 -// return new DOMParser().parseFromString(text, contentType); -// } else { -// // fallback: 以 urlToDocumentInContentPage 方式取得 -// const blob = new Blob([text], { type: contentType }); -// const blobURL = await toBlobURL(a, blob); -// const document = await urlToDocumentInContentPage(a, blobURL); -// return document; -// } -// }; - // GM_Base 定义内部用变量和函数。均使用@protected // 暂不考虑 Object.getOwnPropertyNames(GM_Base.prototype) 和 ts-morph 脚本生成 class GM_Base implements IGM_Base { @@ -892,566 +789,18 @@ export default class GMApi extends GM_Base { }); } - static _GM_xmlhttpRequest( - a: GMApi, - details: GMTypes.XHRDetails, - requirePromise: boolean, - byPassConnect: 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, - byPassConnect: byPassConnect, - }; - 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("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; - } - }, - }; - } - // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API({ depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"], }) public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - const { abort } = _GM_xmlhttpRequest(this, details, false); + 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 { retPromise, abort } = GM_xmlhttpRequest(this, details, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1641,7 +990,7 @@ export default class GMApi extends GM_Base { xhrParams.password = details.password || ""; } // -- 其他参数 -- - const { retPromise, abort } = _GM_xmlhttpRequest(a, xhrParams, true, true); + const { retPromise, abort } = GM_xmlhttpRequest(a, xhrParams, true, true); retPromise?.catch(() => { if (aborted) return; retPromiseReject?.(new Error("Native Download ERROR")); @@ -2013,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, _GM_download } = 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/gm_context.ts similarity index 96% rename from src/app/service/content/gm_context.ts rename to src/app/service/content/gm/gm_context.ts index 13564a578..25bfaa109 100644 --- a/src/app/service/content/gm_context.ts +++ b/src/app/service/content/gm/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/gm_info.ts similarity index 92% rename from src/app/service/content/gm_info.ts rename to src/app/service/content/gm/gm_info.ts index bd698a22e..37b39c535 100644 --- a/src/app/service/content/gm_info.ts +++ b/src/app/service/content/gm/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/gm_xhr.ts b/src/app/service/content/gm/gm_xhr.ts new file mode 100644 index 000000000..fe3309213 --- /dev/null +++ b/src/app/service/content/gm/gm_xhr.ts @@ -0,0 +1,628 @@ +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, + byPassConnect: 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, + byPassConnect: byPassConnect, + }; + 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("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 f990b3406..c010d67c4 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -1,5 +1,5 @@ import type { IGetSender, Group } from "@Packages/message/server"; -import { bgXhrInterface } from "../service_worker/xhr_interface"; +import { bgXhrInterface } from "../service_worker/gm_api/xhr_interface"; export default class GMApi { constructor(private group: Group) {} 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 100% rename from src/app/service/service_worker/gm_api.test.ts rename to src/app/service/service_worker/gm_api/gm_api.test.ts diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts similarity index 99% rename from src/app/service/service_worker/gm_api.ts rename to src/app/service/service_worker/gm_api/gm_api.ts index c2ae56f01..a057a688e 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -6,10 +6,10 @@ import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Package import { connect, sendMessage } from "@Packages/message/client"; import type { IMessageQueue } from "@Packages/message/message_queue"; 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 { type RuntimeService } from "./runtime"; +import { type RuntimeService } from "../runtime"; import { getIcon, isFirefox, openInCurrentTab, cleanFileName, urlSanitize } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; import i18next, { i18nName } from "@App/locales/locales"; @@ -24,15 +24,15 @@ 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 { bgXhrInterface } from "./xhr_interface"; +import { createObjectURL } from "../../offscreen/client"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { bgXhrInterface } from "./xhr_interface"; const askUnlistedConnect = false; const askConnectStar = true; diff --git a/src/app/service/service_worker/xhr_interface.ts b/src/app/service/service_worker/gm_api/xhr_interface.ts similarity index 96% rename from src/app/service/service_worker/xhr_interface.ts rename to src/app/service/service_worker/gm_api/xhr_interface.ts index bf60fd3fd..d4e847724 100644 --- a/src/app/service/service_worker/xhr_interface.ts +++ b/src/app/service/service_worker/gm_api/xhr_interface.ts @@ -1,6 +1,6 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; -import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/utils_datatype"; -import { bgXhrRequestFn } from "@App/pkg/utils/xhr_bg_core"; +import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/datatype"; +import { bgXhrRequestFn } from "@App/pkg/utils/xhr/xhr_bg_core"; import { type MessageConnect, type TMessageCommAction } from "@Packages/message/types"; /** diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index a7c402672..c63cca575 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -13,7 +13,7 @@ import { calculateHashFromArrayBuffer } from "@App/pkg/utils/crypto"; import { isBase64, parseUrlSRI } from "./utils"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { swFetch } from "@App/pkg/utils/sw_fetch"; -import { blobToUint8Array } from "@App/pkg/utils/utils_datatype"; +import { blobToUint8Array } from "@App/pkg/utils/datatype"; export class ResourceService { logger: Logger; 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/pkg/utils/utils_datatype.ts b/src/pkg/utils/datatype.ts similarity index 100% rename from src/pkg/utils/utils_datatype.ts rename to src/pkg/utils/datatype.ts diff --git a/src/pkg/utils/xhr_bg_core.ts b/src/pkg/utils/xhr/xhr_bg_core.ts similarity index 100% rename from src/pkg/utils/xhr_bg_core.ts rename to src/pkg/utils/xhr/xhr_bg_core.ts diff --git a/src/pkg/utils/xhr_data.ts b/src/pkg/utils/xhr/xhr_data.ts similarity index 99% rename from src/pkg/utils/xhr_data.ts rename to src/pkg/utils/xhr/xhr_data.ts index 9ffcac618..405c2b59f 100644 --- a/src/pkg/utils/xhr_data.ts +++ b/src/pkg/utils/xhr/xhr_data.ts @@ -1,4 +1,4 @@ -import { base64ToUint8, uint8ToBase64 } from "./utils_datatype"; +import { base64ToUint8, uint8ToBase64 } from "../datatype"; export const typedArrayTypes = [ Int8Array, From 36ad26a569a816868f996d5515881510b0e2a0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 12 Nov 2025 15:53:06 +0800 Subject: [PATCH 24/44] =?UTF-8?q?=E5=88=A0=E9=99=A4byPass=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E9=81=BF=E5=85=8D=E9=A5=B6=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E5=8F=AF=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm/gm_xhr.ts | 5 ++--- src/app/service/service_worker/gm_api/gm_api.ts | 15 +++++---------- .../service/service_worker/permission_verify.ts | 7 +------ tests/utils.ts | 11 +++-------- 4 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/app/service/content/gm/gm_xhr.ts b/src/app/service/content/gm/gm_xhr.ts index fe3309213..0b030f33d 100644 --- a/src/app/service/content/gm/gm_xhr.ts +++ b/src/app/service/content/gm/gm_xhr.ts @@ -83,7 +83,7 @@ export function GM_xmlhttpRequest( a: GMApi, details: GMTypes.XHRDetails, requirePromise: boolean, - byPassConnect: boolean = false + isDownload: boolean = false ) { let reqDone = false; if (a.isInvalidContext()) { @@ -126,7 +126,6 @@ export function GM_xmlhttpRequest( password: details.password, redirect: details.redirect, fetch: details.fetch, - byPassConnect: byPassConnect, }; if (!param.headers) { param.headers = {}; @@ -174,7 +173,7 @@ export function GM_xmlhttpRequest( const responseType = responseTypeOriginal; // 回传用 // 发送信息 - a.connect("GM_xmlhttpRequest", [param]).then((con) => { + a.connect(isDownload ? "GM_download" : "GM_xmlhttpRequest", [param]).then((con) => { // 注意。在此 callback 里,不应直接存取 param, 否则会影响 GC connect = con; const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index a057a688e..8b21fcfb4 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -284,21 +284,16 @@ export default class GMApi { // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); - let byPass = false; - if (data.api === "GM_xmlhttpRequest" && data.params?.[0]?.byPassConnect === true) byPass = true; const api = PermissionVerifyApiGet(data.api); if (!api) { throw new Error("gm api is not found"); } const req = await this.parseRequest(data); - if (!byPass && this.permissionVerify.noVerify(req, api, sender)) byPass = true; - if (!byPass) { - try { - await this.permissionVerify.verify(req, api, sender, this); - } catch (e) { - this.logger.error("verify error", { api: data.api }, Logger.E(e)); - throw e; - } + try { + await this.permissionVerify.verify(req, api, sender, this); + } catch (e) { + this.logger.error("verify error", { api: data.api }, Logger.E(e)); + throw e; } return api.api.call(this, req, sender); } diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index 7cc5b2540..a6626ca0d 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -11,7 +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"; +import type GMApi from "./gm_api/gm_api"; export interface ConfirmParam { // 权限名 @@ -116,11 +116,6 @@ export default class PermissionVerify { this.permissionDAO.enableCache(); } - noVerify(_request: GMApiRequest, _api: ApiValue, _sender: IGetSender) { - // 测试用 - return false; - } - // 验证是否有权限 async verify(request: GMApiRequest, api: ApiValue, sender: IGetSender, GMApiInstance: GMApi): Promise { const { alias, link, confirm } = api.param; diff --git a/tests/utils.ts b/tests/utils.ts index 2d07c328e..22d8195f4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,16 +1,15 @@ import LoggerCore, { EmptyWriter } from "@App/app/logger/core"; import { MockMessage } from "@Packages/message/mock_message"; -import { type IGetSender, Server } 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 PermissionVerify, { type ApiValue } from "@App/app/service/service_worker/permission_verify"; -import { type GMApiRequest } from "@App/app/service/service_worker/types"; +import PermissionVerify from "@App/app/service/service_worker/permission_verify"; export function initTestEnv() { // @ts-ignore @@ -46,10 +45,6 @@ export function initTestGMApi(): Message { const valueService = new ValueService(serviceWorkerServer.group("value"), messageQueue); const permissionVerify = new PermissionVerify(serviceWorkerServer.group("permissionVerify"), messageQueue); (permissionVerify as any).confirmWindowActual = permissionVerify.confirmWindow; - permissionVerify.noVerify = function (request: GMApiRequest, _api: ApiValue, _sender: IGetSender) { - if (noConfirmScripts.has(request.uuid)) return true; - return false; - }; const swGMApi = new GMApi( systemConfig, permissionVerify, From db8642eabfc98d5e06a276e5d1b5edff8f4d3406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 12 Nov 2025 17:02:40 +0800 Subject: [PATCH 25/44] =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 6 +++--- src/app/service/content/exec_script.ts | 4 ++-- src/app/service/content/{gm => gm_api}/gm_api.test.ts | 0 src/app/service/content/{gm => gm_api}/gm_api.ts | 0 src/app/service/content/{gm => gm_api}/gm_context.ts | 0 src/app/service/content/{gm => gm_api}/gm_info.ts | 0 src/app/service/content/{gm => gm_api}/gm_xhr.ts | 0 tests/runtime/gm_api.test.ts | 2 +- tests/utils.ts | 7 +++++++ 9 files changed, 13 insertions(+), 6 deletions(-) rename src/app/service/content/{gm => gm_api}/gm_api.test.ts (100%) rename src/app/service/content/{gm => gm_api}/gm_api.ts (100%) rename src/app/service/content/{gm => gm_api}/gm_context.ts (100%) rename src/app/service/content/{gm => gm_api}/gm_info.ts (100%) rename src/app/service/content/{gm => gm_api}/gm_xhr.ts (100%) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 06216ce91..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/gm_context"; -import { protect } from "./gm/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/gm_api"; +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 8cfdaad60..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/gm_info"; -import type { IGM_Base } from "./gm/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/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts similarity index 100% rename from src/app/service/content/gm/gm_api.test.ts rename to src/app/service/content/gm_api/gm_api.test.ts diff --git a/src/app/service/content/gm/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts similarity index 100% rename from src/app/service/content/gm/gm_api.ts rename to src/app/service/content/gm_api/gm_api.ts diff --git a/src/app/service/content/gm/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts similarity index 100% rename from src/app/service/content/gm/gm_context.ts rename to src/app/service/content/gm_api/gm_context.ts diff --git a/src/app/service/content/gm/gm_info.ts b/src/app/service/content/gm_api/gm_info.ts similarity index 100% rename from src/app/service/content/gm/gm_info.ts rename to src/app/service/content/gm_api/gm_info.ts diff --git a/src/app/service/content/gm/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts similarity index 100% rename from src/app/service/content/gm/gm_xhr.ts rename to src/app/service/content/gm_api/gm_xhr.ts diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index bc603312a..b4a820133 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -1,5 +1,5 @@ import { type Script, ScriptDAO, type ScriptRunResource } from "@App/app/repo/scripts"; -import GMApi from "@App/app/service/content/gm_api"; +import GMApi from "@App/app/service/content/gm_api/gm_api"; import { randomUUID } from "crypto"; import { afterAll, beforeAll, describe, expect, it, vi, vitest } from "vitest"; import { addTestPermission, initTestGMApi } from "@Tests/utils"; diff --git a/tests/utils.ts b/tests/utils.ts index 22d8195f4..bb5232ca3 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,5 +1,6 @@ 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"; @@ -9,7 +10,9 @@ 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 @@ -45,6 +48,10 @@ export function initTestGMApi(): Message { 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, From 7e2ff637d7dccb1a30297285c1dc15674962c07e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:29:55 +0900 Subject: [PATCH 26/44] =?UTF-8?q?=E6=94=B9=E5=96=84=E4=BA=86reqID=E5=85=B3?= =?UTF-8?q?=E8=81=94=E7=9A=84=E4=BB=A3=E7=A0=81=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/gm_api/gm_api.ts | 266 ++++++++++-------- src/app/service/service_worker/index.ts | 3 +- src/app/service/service_worker/resource.ts | 3 +- src/app/service/service_worker/system.ts | 3 +- src/pkg/utils/script.ts | 3 +- src/pkg/utils/sw_fetch.ts | 25 -- 6 files changed, 158 insertions(+), 145 deletions(-) delete mode 100644 src/pkg/utils/sw_fetch.ts diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 8b21fcfb4..32c262bff 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -10,7 +10,7 @@ import type { ConfirmParam } from "../permission_verify"; import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify"; import { cacheInstance } from "@App/app/cache"; import { type RuntimeService } from "../runtime"; -import { getIcon, isFirefox, openInCurrentTab, cleanFileName, urlSanitize } from "@App/pkg/utils/utils"; +import { getIcon, isFirefox, openInCurrentTab, cleanFileName, urlSanitize, sleep } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; import i18next, { i18nName } from "@App/locales/locales"; import FileSystemFactory from "@Packages/filesystem/factory"; @@ -37,6 +37,9 @@ import { bgXhrInterface } from "./xhr_interface"; const askUnlistedConnect = false; const askConnectStar = true; +let lastNwReqTriggerTime = 0; +const nwReqExecutes = new Set<{ initiatedAfter: number; resolve: () => void }>(); +const nwReqIdCollects = new Map(); const scXhrRequests = new Map(); // 关联SC后台发出的 xhr/fetch 的 requestId const redirectedUrls = new Map(); // 关联SC后台发出的 xhr/fetch 的 redirectUrl const nwErrorResults = new Map(); // 关联SC后台发出的 xhr/fetch 的 network error @@ -57,55 +60,48 @@ const headerModifierMap = new Map< } >(); -type TXhrReqObject = { - reqUrl: string; - markerId: string; - resolve?: ((value?: unknown) => void) | null; - startTime: number; -}; - const enum xhrExtraCode { INVALID_URL = 0x20, DOMAIN_NOT_INCLUDED = 0x30, DOMAIN_IN_BLACKLIST = 0x40, } -const xhrReqEntries = new Map(); - -const setReqDone = (stdUrl: string, xhrReqEntry: TXhrReqObject) => { - xhrReqEntry.reqUrl = ""; - xhrReqEntry.markerId = ""; - xhrReqEntry.startTime = 0; - xhrReqEntry.resolve?.(); - xhrReqEntry.resolve = null; - xhrReqEntries.delete(stdUrl); -}; - -const setReqId = (reqId: string, url: string, timeStamp: number) => { - const stdUrl = urlSanitize(url); - const xhrReqEntry = xhrReqEntries.get(stdUrl); - if (xhrReqEntry) { - const { reqUrl, markerId } = xhrReqEntry; - if (reqUrl !== url && `URL::${urlSanitize(reqUrl)}` !== `URL::${stdUrl}`) { - // 通常不会发生 - console.error("xhrReqEntry URL mistached", reqUrl, url); - setReqDone(stdUrl, xhrReqEntry); - } else if (!xhrReqEntry.startTime || !(timeStamp > xhrReqEntry.startTime)) { - // 通常不会发生 - console.error("xhrReqEntry timeStamp issue 1", xhrReqEntry.startTime, timeStamp); - setReqDone(stdUrl, xhrReqEntry); - } else if (timeStamp - xhrReqEntry.startTime > 400) { - // 通常不会发生 - console.error("xhrReqEntry timeStamp issue 2", xhrReqEntry.startTime, timeStamp); - setReqDone(stdUrl, xhrReqEntry); - } else { - // console.log("xhrReqEntry", xhrReqEntry.startTime, timeStamp); // 相隔 2 ~ 9 ms - scXhrRequests.set(markerId, reqId); // 同时存放 (markerID -> reqId) - scXhrRequests.set(reqId, markerId); // 同时存放 (reqId -> markerID) - setReqDone(stdUrl, xhrReqEntry); - } - } -}; +// const xhrReqEntries = new Map(); + +// const setReqDone = (stdUrl: string, xhrReqEntry: TXhrReqObject) => { +// xhrReqEntry.reqUrl = ""; +// xhrReqEntry.markerId = ""; +// xhrReqEntry.startTime = 0; +// xhrReqEntry.resolve?.(); +// xhrReqEntry.resolve = null; +// xhrReqEntries.delete(stdUrl); +// }; + +// const setReqId = (reqId: string, url: string, timeStamp: number) => { +// const stdUrl = urlSanitize(url); +// const xhrReqEntry = xhrReqEntries.get(stdUrl); +// if (xhrReqEntry) { +// const { reqUrl, markerId } = xhrReqEntry; +// if (reqUrl !== url && `URL::${urlSanitize(reqUrl)}` !== `URL::${stdUrl}`) { +// // 通常不会发生 +// console.error("xhrReqEntry URL mistached", reqUrl, url); +// setReqDone(stdUrl, xhrReqEntry); +// } else if (!xhrReqEntry.startTime || !(timeStamp > xhrReqEntry.startTime)) { +// // 通常不会发生 +// console.error("xhrReqEntry timeStamp issue 1", xhrReqEntry.startTime, timeStamp); +// setReqDone(stdUrl, xhrReqEntry); +// } else if (timeStamp - xhrReqEntry.startTime > 400) { +// // 通常不会发生 +// console.error("xhrReqEntry timeStamp issue 2", xhrReqEntry.startTime, timeStamp); +// setReqDone(stdUrl, xhrReqEntry); +// } else { +// // console.log("xhrReqEntry", xhrReqEntry.startTime, timeStamp); // 相隔 2 ~ 9 ms +// scXhrRequests.set(markerId, reqId); // 同时存放 (markerID -> reqId) +// scXhrRequests.set(reqId, markerId); // 同时存放 (reqId -> markerID) +// setReqDone(stdUrl, xhrReqEntry); +// } +// } +// }; // GMApi,处理脚本的GM API调用请求 @@ -583,15 +579,17 @@ export default class GMApi { // 添加请求header const headers = params.headers || (params.headers = {}); const { anonymous, cookie } = params; - // 采用legacy命名方式,以大写,X- 开头 + // 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}`; + headers["x-sc-request-marker"] = `${markerID}`; - // 不使用"X-SC-Request-Marker", 避免 modifyHeaders DNR 和 chrome.webRequest.onBeforeSendHeaders 的执行次序问题 + // 关联 reqID 方法 + // 1) 尝试在 onBeforeRequest 进行关连 + // 2) 如果在 chrome.webRequest.onBeforeSendHeaders 执行时,modifyHeaders DNR 未被执行,则以 "x-sc-request-marker" 进行关连 // 如果header中没有origin就设置为空字符串,如果有origin就不做处理,注意处理大小写 if (typeof headers["Origin"] !== "string" && typeof headers["origin"] !== "string") { @@ -1026,25 +1024,38 @@ export default class GMApi { } }; - // stackAsyncTask 是为了 chrome.webRequest.onBeforeRequest 能捕捉当前 XHR 的 id - // 旧SC使用 modiftyHeader DNR Rule + chrome.webRequest.onBeforeSendHeaders 捕捉 - // 但这种方式可能会随DNR规范改变而失效,因为 modiftyHeader DNR Rule 不保证必定发生在 onBeforeSendHeaders 前 - await stackAsyncTask(`nwRequest::${stdUrl}`, async () => { - const xhrReqEntry = { - reqUrl: requestUrl, - markerId: markerID, - startTime: Date.now() - 1, // -1 to avoid floating number rounding - } as TXhrReqObject; - const ret = new Promise((resolve) => { - xhrReqEntry.resolve = resolve; + // stackAsyncTask 确保 nwReqIdCollects{"stdUrl"} 单一执行 + await stackAsyncTask(`nwReqIdCollects::${stdUrl}`, async () => { + // 收集网络请求 + const collection = [] as string[]; + const initiatedAfter = lastNwReqTriggerTime; + nwReqIdCollects.set(stdUrl, { + initiatedAfter, // filter req >= initiatedAfter. 避免检测出Req发出前的其他Req + list: collection, }); - xhrReqEntries.set(stdUrl, xhrReqEntry); + // 不理会URL,只判断网络请求是否已发出至少一次 + const ret = new Promise((resolve) => { + nwReqExecutes.add({ + initiatedAfter, + resolve, + }); + }); + // 网络请求发出前。 try { await f(); - } catch { - setReqDone(stdUrl, xhrReqEntry); + } catch (e: any) { + console.warn(e); + } + // 网络请求发出后。 + await sleep(1); // next marco event + await ret; // 至少 onBeforeRequest 有被触发一次 + await sleep(1); // next marco event + nwReqIdCollects.delete(stdUrl); + if (collection.length === 1 && collection[0].length > 0) { + const reqId = collection[0]; + scXhrRequests.set(reqId, markerID); } - return ret; + collection.length = 0; }); } catch (e: any) { throw throwErrorFn(`GM_xmlhttpRequest ERROR: ${e?.message || e || "Unknown Error"}`); @@ -1501,7 +1512,16 @@ export default class GMApi { // webRequest API 出错不进行后续处理 return undefined; } - if (xhrReqEntries.size) { + const timeStamp = (lastNwReqTriggerTime = details.timeStamp); + if (nwReqExecutes.size) { + for (const nwReqExecute of nwReqExecutes) { + if (timeStamp >= nwReqExecute.initiatedAfter) { + nwReqExecute.resolve(); + nwReqExecutes.delete(nwReqExecute); + } + } + } + if (nwReqIdCollects.size) { if ( details.tabId === -1 && details.requestId && @@ -1509,7 +1529,17 @@ export default class GMApi { (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) && !scXhrRequests.has(details.requestId) ) { - setReqId(details.requestId, details.url, details.timeStamp); + try { + const stdUrl = urlSanitize(details.url); + const collection = nwReqIdCollects.get(stdUrl); + if (collection) { + if (timeStamp >= collection.initiatedAfter) { + collection.list.push(details.requestId); + } + } + } catch (e) { + console.warn(e); + } } } }, @@ -1556,17 +1586,6 @@ export default class GMApi { // webRequest API 出错不进行后续处理 return undefined; } - // if (xhrReqEntries.size) { - // if ( - // details.tabId === -1 && - // details.requestId && - // details.url && - // (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) && - // !scXhrRequests.has(details.requestId) - // ) { - // setReqId(details.requestId, details.url, details.timeStamp); - // } - // } if (details.tabId === -1) { const markerID = scXhrRequests.get(details.requestId); if (markerID) { @@ -1633,39 +1652,15 @@ export default class GMApi { } if (details.tabId === -1) { 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; redirectedUrls.set(markerID, details.url); - - // if (myRequests.has(details.requestId)) { - // const markerID = myRequests.get(details.requestId); - // if (markerID) { - // redirectedUrls.set(markerID, details.url); - // } - // } else { - // // Chrome: 目前 modifyHeaders DNR 会较 chrome.webRequest.onBeforeSendHeaders 后执行 - // // 如日后API行为改变,需要改用 onBeforeRequest,且每次等 fetch/xhr 触发 onBeforeRequest 后才能执行下一个 fetch/xhr - // const headers = details.requestHeaders; - // // 讲请求id与chrome.webRequest的请求id关联 - // if (headers) { - // // 自订header可能会被转为小写,例如fetch API - // // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers - // if (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) { - // const idx = headers.findIndex((h) => h.name.toLowerCase() === "x-sc-request-marker"); - // if (idx !== -1) { - // const markerID = headers[idx].value; - // if (typeof markerID === "string") { - // // 请求id关联 - // const reqId = details.requestId; - // myRequests.set(markerID, reqId); // 同时存放 (markerID -> reqId) - // myRequests.set(reqId, markerID); // 同时存放 (reqId -> markerID) - // redirectedUrls.set(markerID, details.url); - // } - // } - // } - // } - // } } return undefined; }, @@ -1736,16 +1731,32 @@ export default class GMApi { }, }; headerModifierMap.set(markerID, { rule: newRule, redirectNotManual }); - chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [rule.id], - addRules: [newRule], - }); + 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], - }); + chrome.declarativeNetRequest.updateSessionRules( + { + removeRuleIds: [rule.id], + }, + () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); + } + } + ); } } } @@ -1757,6 +1768,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/index.ts b/src/app/service/service_worker/index.ts index b338fe8ef..ee22d0d32 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -18,7 +18,6 @@ import { localePath, t } from "@App/locales/locales"; import { getCurrentTab, InfoNotification } from "@App/pkg/utils/utils"; import { onTabRemoved, onUrlNavigated, setOnUserActionDomainChanged } from "./url_monitor"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; -import { swFetch } from "@App/pkg/utils/sw_fetch"; // service worker的管理器 export default class ServiceWorkerManager { @@ -266,7 +265,7 @@ export default class ServiceWorkerManager { } checkUpdate() { - swFetch(`${ExtServer}api/v1/system/version?version=${ExtVersion}`) + fetch(`${ExtServer}api/v1/system/version?version=${ExtVersion}`) .then((resp) => resp.json()) .then((resp: { data: { notice: string; version: string } }) => { systemConfig diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index c63cca575..ea026ff4a 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -12,7 +12,6 @@ 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 { swFetch } from "@App/pkg/utils/sw_fetch"; import { blobToUint8Array } from "@App/pkg/utils/datatype"; export class ResourceService { @@ -259,7 +258,7 @@ export class ResourceService { async loadByUrl(url: string, type: ResourceType): Promise { const u = parseUrlSRI(url); - const resp = await swFetch(u.url); + const resp = await fetch(u.url); if (resp.status !== 200) { throw new Error(`resource response status not 200: ${resp.status}`); } diff --git a/src/app/service/service_worker/system.ts b/src/app/service/service_worker/system.ts index 3eff26f24..0679228f0 100644 --- a/src/app/service/service_worker/system.ts +++ b/src/app/service/service_worker/system.ts @@ -5,7 +5,6 @@ import { createObjectURL, VscodeConnectClient } from "../offscreen/client"; import { cacheInstance } from "@App/app/cache"; import { CACHE_KEY_FAVICON } from "@App/app/cache_key"; import { fetchIconByDomain } from "./fetch"; -import { swFetch } from "@App/pkg/utils/sw_fetch"; // 一些系统服务 export class SystemService { @@ -29,7 +28,7 @@ export class SystemService { // 对url做一个缓存 const cacheKey = `${CACHE_KEY_FAVICON}${url}`; return cacheInstance.getOrSet(cacheKey, async () => { - return swFetch(url) + return fetch(url) .then((response) => response.blob()) .then((blob) => createObjectURL(this.msgSender, blob, true)) .catch(() => { diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index 10891ab40..e8219607e 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -15,7 +15,6 @@ import { SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe"; import { nextTime } from "./cron"; import { parseUserConfig } from "./yaml"; import { t as i18n_t } from "@App/locales/locales"; -import { swFetch } from "./sw_fetch"; // 从脚本代码抽出Metadata export function parseMetadata(code: string): SCMetadata | null { @@ -60,7 +59,7 @@ export function parseMetadata(code: string): SCMetadata | null { // 从网址取得脚本代码 export async function fetchScriptBody(url: string): Promise { - const resp = await swFetch(url, { + const resp = await fetch(url, { headers: { "Cache-Control": "no-cache", }, diff --git a/src/pkg/utils/sw_fetch.ts b/src/pkg/utils/sw_fetch.ts deleted file mode 100644 index c3e0833a4..000000000 --- a/src/pkg/utils/sw_fetch.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { stackAsyncTask } from "@App/pkg/utils/async_queue"; -import { urlSanitize } from "@App/pkg/utils/utils"; - -export const swFetch = (input: string | URL | Request, init?: RequestInit) => { - let url; - if (typeof input === "string") { - url = input; - } else if (typeof (input as any)?.href === "string") { - url = (input as any).href; - } else if (typeof (input as any)?.url === "string") { - url = (input as any).url; - } - let stdUrl; - if (url) { - try { - stdUrl = urlSanitize(url); - } catch { - // ignored - } - } - if (!stdUrl) return fetch(input, init); - // 鎖一下 nwRequest 防止與 GM_xhr 竞争 - - return stackAsyncTask(`nwRequest::${stdUrl}`, () => fetch(input, init)); -}; From 9c8b4f159ee9d97cabde56ac73efca6b125925ed Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:40:06 +0900 Subject: [PATCH 27/44] =?UTF-8?q?typescript=20=E8=A6=81=20return=20undefin?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 32c262bff..175c46544 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1659,7 +1659,7 @@ export default class GMApi { if (markerID) scXhrRequests.set(reqId, markerID); } const markerID = scXhrRequests.get(reqId); - if (!markerID) return; + if (!markerID) return undefined; redirectedUrls.set(markerID, details.url); } return undefined; From 091a984cb4945afa4473561df673cc5235700ff6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:41:55 +0900 Subject: [PATCH 28/44] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20lastNwReqTriggerTime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 175c46544..78fe4ab20 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1512,7 +1512,8 @@ export default class GMApi { // webRequest API 出错不进行后续处理 return undefined; } - const timeStamp = (lastNwReqTriggerTime = details.timeStamp); + const timeStamp = details.timeStamp; + if (timeStamp > lastNwReqTriggerTime) lastNwReqTriggerTime = timeStamp; if (nwReqExecutes.size) { for (const nwReqExecute of nwReqExecutes) { if (timeStamp >= nwReqExecute.initiatedAfter) { From 7ebea0bfd45352c08e5b954202fef50ac6a68b67 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:48:22 +0900 Subject: [PATCH 29/44] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 78fe4ab20..254dedb6a 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1053,7 +1053,10 @@ export default class GMApi { nwReqIdCollects.delete(stdUrl); if (collection.length === 1 && collection[0].length > 0) { const reqId = collection[0]; - scXhrRequests.set(reqId, markerID); + // 如 onBeforeSendHeaders 已执行并关联了 reqId, 则不处理 + if (!scXhrRequests.has(reqId)) { + scXhrRequests.set(reqId, markerID); + } } collection.length = 0; }); From 1fc53ea3bc156bceb44ddeaa669ca3d85a3d6121 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:53:58 +0900 Subject: [PATCH 30/44] =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文注释 --- src/app/service/service_worker/gm_api/gm_api.ts | 3 ++- src/pkg/utils/utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 254dedb6a..0e6b38b14 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1048,9 +1048,10 @@ export default class GMApi { } // 网络请求发出后。 await sleep(1); // next marco event - await ret; // 至少 onBeforeRequest 有被触发一次 + await ret; // onBeforeRequest 触发至少一次 await sleep(1); // next marco event nwReqIdCollects.delete(stdUrl); + // 尝试在 onBeforeRequest 階段关联 reqId,避免 onBeforeSendHeaders 时关联失败的可能性 if (collection.length === 1 && collection[0].length > 0) { const reqId = collection[0]; // 如 onBeforeSendHeaders 已执行并关联了 reqId, 则不处理 diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index a157f11e4..8b6d040c8 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -371,9 +371,9 @@ export const stringMatching = (main: string, sub: string): boolean => { }; export const urlSanitize = (url: string) => { - const u = new URL(url); // 利用 URL 處理 URL Encoding 問題。 + const u = new URL(url); // 利用 URL 处理 URL Encoding 问题。 // 例如 'https://日月.baidu.com/你好' => 'https://xn--wgv4y.baidu.com/%E4%BD%A0%E5%A5%BD' - // 為方便控制,只需要考慮 orign 和 pathname 的匹對 + // 为方便控制,只需要考虑 orign 和 pathname 的匹对 // https://user:passwd@httpbun.com/basic-auth/user/passwd -> https://httpbun.com/basic-auth/user/passwd return `URL::${u.origin}${u.pathname}`; }; From 73ac04b25f8544ea562748111845e5bda8c1a784 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:10:46 +0900 Subject: [PATCH 31/44] =?UTF-8?q?=E5=88=A0=E6=97=A7=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/gm_api/gm_api.ts | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 0e6b38b14..a66d8fede 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -66,43 +66,6 @@ const enum xhrExtraCode { DOMAIN_IN_BLACKLIST = 0x40, } -// const xhrReqEntries = new Map(); - -// const setReqDone = (stdUrl: string, xhrReqEntry: TXhrReqObject) => { -// xhrReqEntry.reqUrl = ""; -// xhrReqEntry.markerId = ""; -// xhrReqEntry.startTime = 0; -// xhrReqEntry.resolve?.(); -// xhrReqEntry.resolve = null; -// xhrReqEntries.delete(stdUrl); -// }; - -// const setReqId = (reqId: string, url: string, timeStamp: number) => { -// const stdUrl = urlSanitize(url); -// const xhrReqEntry = xhrReqEntries.get(stdUrl); -// if (xhrReqEntry) { -// const { reqUrl, markerId } = xhrReqEntry; -// if (reqUrl !== url && `URL::${urlSanitize(reqUrl)}` !== `URL::${stdUrl}`) { -// // 通常不会发生 -// console.error("xhrReqEntry URL mistached", reqUrl, url); -// setReqDone(stdUrl, xhrReqEntry); -// } else if (!xhrReqEntry.startTime || !(timeStamp > xhrReqEntry.startTime)) { -// // 通常不会发生 -// console.error("xhrReqEntry timeStamp issue 1", xhrReqEntry.startTime, timeStamp); -// setReqDone(stdUrl, xhrReqEntry); -// } else if (timeStamp - xhrReqEntry.startTime > 400) { -// // 通常不会发生 -// console.error("xhrReqEntry timeStamp issue 2", xhrReqEntry.startTime, timeStamp); -// setReqDone(stdUrl, xhrReqEntry); -// } else { -// // console.log("xhrReqEntry", xhrReqEntry.startTime, timeStamp); // 相隔 2 ~ 9 ms -// scXhrRequests.set(markerId, reqId); // 同时存放 (markerID -> reqId) -// scXhrRequests.set(reqId, markerId); // 同时存放 (reqId -> markerID) -// setReqDone(stdUrl, xhrReqEntry); -// } -// } -// }; - // GMApi,处理脚本的GM API调用请求 type RequestResultParams = { From 776844af81d273eb108ce5b13678926e2cd3df03 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:23:29 +0900 Subject: [PATCH 32/44] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E6=88=90=20generateUni?= =?UTF-8?q?queMarkerID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/gm_api/gm_api.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index a66d8fede..fb7e08bd5 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -60,6 +60,25 @@ const headerModifierMap = new Map< } >(); +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, @@ -729,9 +748,7 @@ export default class GMApi { // 关联自己生成的请求id与chrome.webRequest的请求id // 随机生成(同步),不需要 chrome.storage 存取 - const u1 = Math.floor(Date.now()).toString(36); - const u2 = Math.floor(Math.random() * 2514670967279938 + 1045564536402193).toString(36); - const markerID = `MARKER::${u1}_${u2}`; + const markerID = generateUniqueMarkerID(); const isRedirectError = request.params?.[0]?.redirect === "error"; From 1ee5916ee2fccd6a593710d66c1bc294fb62390b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:45:25 +0900 Subject: [PATCH 33/44] =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index fb7e08bd5..912798e96 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1027,11 +1027,12 @@ export default class GMApi { console.warn(e); } // 网络请求发出后。 - await sleep(1); // next marco event - await ret; // onBeforeRequest 触发至少一次 - await sleep(1); // next marco event + await sleep(1); // next marco event (收集一下网络请求的 onBeforeRequest 触发) + await ret; // onBeforeRequest 触发至少一次 (通常在 sleep(1) 期间已触发) + await sleep(1); // next marco event (在至少一次触发后,等一下避免其他相同网址的网络要求并发) + // 收集结束。检查收集结果。 nwReqIdCollects.delete(stdUrl); - // 尝试在 onBeforeRequest 階段关联 reqId,避免 onBeforeSendHeaders 时关联失败的可能性 + // 尝试在 onBeforeRequest 阶段关联 reqId,避免 onBeforeSendHeaders 时关联失败的可能性 if (collection.length === 1 && collection[0].length > 0) { const reqId = collection[0]; // 如 onBeforeSendHeaders 已执行并关联了 reqId, 则不处理 From ab5389f76357618ffd249eba11d069624b8de281 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:51:46 +0900 Subject: [PATCH 34/44] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 912798e96..52bba9776 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1023,13 +1023,13 @@ export default class GMApi { // 网络请求发出前。 try { await f(); + // 网络请求发出后。 + await sleep(1); // next marco event (收集一下网络请求的 onBeforeRequest 触发) + await ret; // onBeforeRequest 触发至少一次 (通常在 sleep(1) 期间已触发) + await sleep(1); // next marco event (在至少一次触发后,等一下避免其他相同网址的网络要求并发) } catch (e: any) { - console.warn(e); + console.error(e); } - // 网络请求发出后。 - await sleep(1); // next marco event (收集一下网络请求的 onBeforeRequest 触发) - await ret; // onBeforeRequest 触发至少一次 (通常在 sleep(1) 期间已触发) - await sleep(1); // next marco event (在至少一次触发后,等一下避免其他相同网址的网络要求并发) // 收集结束。检查收集结果。 nwReqIdCollects.delete(stdUrl); // 尝试在 onBeforeRequest 阶段关联 reqId,避免 onBeforeSendHeaders 时关联失败的可能性 From 4ac2789701976d492ce9bb2799edd29ce140aa01 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:07:45 +0900 Subject: [PATCH 35/44] =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?= =?UTF-8?q?=EF=BC=9A=20nwReqExecute=20=E8=BF=87=E6=97=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 52bba9776..c0f23bc4d 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1785,6 +1785,20 @@ export default class GMApi { } } ); + // 异常处理 + // 如果SC完全没有任何网络请求,会让 nwReqExecutes 被卡死 + // 可能性很低。只是以防万一 + setInterval(() => { + if (nwReqExecutes.size) { + for (const nwReqExecute of nwReqExecutes) { + if (Date.now() - nwReqExecute.initiatedAfter > 4800) { + // 已经过时。放行吧 + nwReqExecute.resolve(); + nwReqExecutes.delete(nwReqExecute); + } + } + } + }, 5000); } start() { From 71da4ca71222a54c7a312a534cf63ac8646a0bc5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:19:04 +0900 Subject: [PATCH 36/44] =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=80=BC=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index c0f23bc4d..2ac1c0071 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1008,7 +1008,9 @@ export default class GMApi { await stackAsyncTask(`nwReqIdCollects::${stdUrl}`, async () => { // 收集网络请求 const collection = [] as string[]; - const initiatedAfter = lastNwReqTriggerTime; + // 配合 lastNwReqTriggerTime,设置一个时间值条件避免取得本次网络要求发出前的其他Req + let initiatedAfter = Date.now() - 2500; + if (lastNwReqTriggerTime > initiatedAfter) initiatedAfter = lastNwReqTriggerTime; nwReqIdCollects.set(stdUrl, { initiatedAfter, // filter req >= initiatedAfter. 避免检测出Req发出前的其他Req list: collection, From 698d762cec21b9b4d74f27a4e12a83121f5efef3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 13 Nov 2025 23:10:43 +0900 Subject: [PATCH 37/44] =?UTF-8?q?=E5=8B=89=E5=BC=BA=E6=97=A0=E5=B9=B8?= =?UTF-8?q?=E7=A6=8F=EF=BC=8C=E5=8F=AA=E5=A5=BD=E9=80=80=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/gm_api/gm_api.ts | 114 ++---------------- 1 file changed, 7 insertions(+), 107 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 2ac1c0071..1cde050a4 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -10,7 +10,7 @@ import type { ConfirmParam } from "../permission_verify"; import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify"; import { cacheInstance } from "@App/app/cache"; import { type RuntimeService } from "../runtime"; -import { getIcon, isFirefox, openInCurrentTab, cleanFileName, urlSanitize, sleep } from "@App/pkg/utils/utils"; +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"; import FileSystemFactory from "@Packages/filesystem/factory"; @@ -31,15 +31,11 @@ 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 { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { bgXhrInterface } from "./xhr_interface"; const askUnlistedConnect = false; const askConnectStar = true; -let lastNwReqTriggerTime = 0; -const nwReqExecutes = new Set<{ initiatedAfter: number; resolve: () => void }>(); -const nwReqIdCollects = new Map(); const scXhrRequests = new Map(); // 关联SC后台发出的 xhr/fetch 的 requestId const redirectedUrls = new Map(); // 关联SC后台发出的 xhr/fetch 的 redirectUrl const nwErrorResults = new Map(); // 关联SC后台发出的 xhr/fetch 的 network error @@ -865,7 +861,6 @@ export default class GMApi { headerModifierMap.delete(markerID); }; const requestUrl = param1.url; - const stdUrl = urlSanitize(requestUrl); // 确保 url 能执行 urlSanitize 且不会报错 const f = async () => { if (useFetch) { @@ -1004,46 +999,12 @@ export default class GMApi { } }; - // stackAsyncTask 确保 nwReqIdCollects{"stdUrl"} 单一执行 - await stackAsyncTask(`nwReqIdCollects::${stdUrl}`, async () => { - // 收集网络请求 - const collection = [] as string[]; - // 配合 lastNwReqTriggerTime,设置一个时间值条件避免取得本次网络要求发出前的其他Req - let initiatedAfter = Date.now() - 2500; - if (lastNwReqTriggerTime > initiatedAfter) initiatedAfter = lastNwReqTriggerTime; - nwReqIdCollects.set(stdUrl, { - initiatedAfter, // filter req >= initiatedAfter. 避免检测出Req发出前的其他Req - list: collection, - }); - // 不理会URL,只判断网络请求是否已发出至少一次 - const ret = new Promise((resolve) => { - nwReqExecutes.add({ - initiatedAfter, - resolve, - }); - }); - // 网络请求发出前。 - try { - await f(); - // 网络请求发出后。 - await sleep(1); // next marco event (收集一下网络请求的 onBeforeRequest 触发) - await ret; // onBeforeRequest 触发至少一次 (通常在 sleep(1) 期间已触发) - await sleep(1); // next marco event (在至少一次触发后,等一下避免其他相同网址的网络要求并发) - } catch (e: any) { - console.error(e); - } - // 收集结束。检查收集结果。 - nwReqIdCollects.delete(stdUrl); - // 尝试在 onBeforeRequest 阶段关联 reqId,避免 onBeforeSendHeaders 时关联失败的可能性 - if (collection.length === 1 && collection[0].length > 0) { - const reqId = collection[0]; - // 如 onBeforeSendHeaders 已执行并关联了 reqId, 则不处理 - if (!scXhrRequests.has(reqId)) { - scXhrRequests.set(reqId, markerID); - } - } - collection.length = 0; - }); + // 网络请求发出前。 + try { + await f(); + } catch (e: any) { + console.error(e); + } } catch (e: any) { throw throwErrorFn(`GM_xmlhttpRequest ERROR: ${e?.message || e || "Unknown Error"}`); } @@ -1491,53 +1452,6 @@ export default class GMApi { // 处理GM_xmlhttpRequest请求 handlerGmXhr() { - chrome.webRequest.onBeforeRequest.addListener( - (details) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRequest:", lastError); - // webRequest API 出错不进行后续处理 - return undefined; - } - const timeStamp = details.timeStamp; - if (timeStamp > lastNwReqTriggerTime) lastNwReqTriggerTime = timeStamp; - if (nwReqExecutes.size) { - for (const nwReqExecute of nwReqExecutes) { - if (timeStamp >= nwReqExecute.initiatedAfter) { - nwReqExecute.resolve(); - nwReqExecutes.delete(nwReqExecute); - } - } - } - if (nwReqIdCollects.size) { - if ( - details.tabId === -1 && - details.requestId && - details.url && - (details.initiator ? `${details.initiator}/`.includes(`/${chrome.runtime.id}/`) : true) && - !scXhrRequests.has(details.requestId) - ) { - try { - const stdUrl = urlSanitize(details.url); - const collection = nwReqIdCollects.get(stdUrl); - if (collection) { - if (timeStamp >= collection.initiatedAfter) { - collection.list.push(details.requestId); - } - } - } catch (e) { - console.warn(e); - } - } - } - }, - { - urls: [""], - types: ["xmlhttprequest"], - tabId: chrome.tabs.TAB_ID_NONE, // 只限于后台 service_worker / offscreen - } - ); - chrome.webRequest.onBeforeRedirect.addListener( (details) => { const lastError = chrome.runtime.lastError; @@ -1787,20 +1701,6 @@ export default class GMApi { } } ); - // 异常处理 - // 如果SC完全没有任何网络请求,会让 nwReqExecutes 被卡死 - // 可能性很低。只是以防万一 - setInterval(() => { - if (nwReqExecutes.size) { - for (const nwReqExecute of nwReqExecutes) { - if (Date.now() - nwReqExecute.initiatedAfter > 4800) { - // 已经过时。放行吧 - nwReqExecute.resolve(); - nwReqExecutes.delete(nwReqExecute); - } - } - } - }, 5000); } start() { From 9a724671ec9e391c93e43989dc0af6932b504d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 14 Nov 2025 14:49:58 +0800 Subject: [PATCH 38/44] =?UTF-8?q?=E6=95=B4=E7=90=86=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/offscreen/gm_api.ts | 5 +- .../service_worker/gm_api/bg_gm_xhr.ts | 145 +++++++++ .../service/service_worker/gm_api/gm_api.ts | 288 +++++------------- .../service/service_worker/gm_api/gm_xhr.ts | 129 ++++++++ .../service_worker/gm_api/xhr_interface.ts | 121 -------- 5 files changed, 347 insertions(+), 341 deletions(-) create mode 100644 src/app/service/service_worker/gm_api/bg_gm_xhr.ts create mode 100644 src/app/service/service_worker/gm_api/gm_xhr.ts delete mode 100644 src/app/service/service_worker/gm_api/xhr_interface.ts diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts index c010d67c4..dd3cb9675 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -1,5 +1,5 @@ import type { IGetSender, Group } from "@Packages/message/server"; -import { bgXhrInterface } from "../service_worker/gm_api/xhr_interface"; +import { BgGMXhr } from "../service_worker/gm_api/bg_gm_xhr"; export default class GMApi { constructor(private group: Group) {} @@ -7,7 +7,8 @@ export default class GMApi { async xmlHttpRequest(details: GMSend.XHRDetails, sender: IGetSender) { const con = sender.getConnect(); // con can be undefined if (!con) throw new Error("offscreen xmlHttpRequest: Connection is undefined"); - bgXhrInterface(details, { finalUrl: "", responseHeaders: "" }, con); + 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/bg_gm_xhr.ts b/src/app/service/service_worker/gm_api/bg_gm_xhr.ts new file mode 100644 index 000000000..d2753b406 --- /dev/null +++ b/src/app/service/service_worker/gm_api/bg_gm_xhr.ts @@ -0,0 +1,145 @@ +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/datatype"; +import { bgXhrRequestFn } from "@App/pkg/utils/xhr/xhr_bg_core"; +import type { MessageConnect, TMessageCommAction } from "@Packages/message/types"; +import type { GMXhrStrategy } from "./gm_xhr"; + +export type RequestResultParams = { + statusCode: number; + responseHeaders: string; + finalUrl: string; +}; + +// 后台处理端 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 & { + // + 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; + } + ) { + 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; + } + + do() { + bgXhrRequestFn(this.details, { + onDataReceived: this.onDataReceived.bind(this), + callback: this.callback.bind(this), + }).catch((e: any) => { + // settings.abort?.(); + console.error(e); + }); + this.msgConn.onDisconnect(() => { + this.isConnDisconnected = true; + // settings.abort?.(); + // console.warn("msgConn.onDisconnect"); + }); + } +} diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 1cde050a4..c1391d4da 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -31,31 +31,22 @@ 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 { bgXhrInterface } from "./xhr_interface"; +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 "./bg_gm_xhr"; const askUnlistedConnect = false; const askConnectStar = true; -const scXhrRequests = new Map(); // 关联SC后台发出的 xhr/fetch 的 requestId -const redirectedUrls = new Map(); // 关联SC后台发出的 xhr/fetch 的 redirectUrl -const nwErrorResults = new Map(); // 关联SC后台发出的 xhr/fetch 的 network error -const nwErrorResultPromises = new Map(); -// net::ERR_NAME_NOT_RESOLVED, net::ERR_CONNECTION_REFUSED, net::ERR_ABORTED, net::ERR_FAILED - -// 接收 xhr/fetch 的 responseHeaders -const headersReceivedMap = new Map< - string, - { responseHeaders: chrome.webRequest.HttpHeader[] | undefined | null; statusCode: number | null } ->(); -// 特殊方式处理:以 DNR Rule per request 方式处理 header 修改 (e.g. cookie, unsafeHeader) -const headerModifierMap = new Map< - string, - { - rule: chrome.declarativeNetRequest.Rule; - redirectNotManual: boolean; - } ->(); - let generatedUniqueMarkerIDs = ""; let generatedUniqueMarkerIDWhen = ""; // 用来生成绝不重复的 MarkerID @@ -81,14 +72,6 @@ const enum xhrExtraCode { DOMAIN_IN_BLACKLIST = 0x40, } -// GMApi,处理脚本的GM API调用请求 - -type RequestResultParams = { - statusCode: number; - responseHeaders: string; - finalUrl: string; -}; - type OnBeforeSendHeadersOptions = `${chrome.webRequest.OnBeforeSendHeadersOptions}`; type OnHeadersReceivedOptions = `${chrome.webRequest.OnHeadersReceivedOptions}`; @@ -746,37 +729,7 @@ export default class GMApi { // 随机生成(同步),不需要 chrome.storage 存取 const markerID = generateUniqueMarkerID(); - const isRedirectError = request.params?.[0]?.redirect === "error"; - - let resultParamStatusCode = 0; - let resultParamResponseHeader = ""; - let resultParamFinalUrl = ""; - const resultParam: RequestResultParams = { - get statusCode() { - const responsed = headersReceivedMap.get(markerID); - if (responsed && typeof responsed.statusCode === "number") { - resultParamStatusCode = responsed.statusCode; - responsed.statusCode = null; // 设为 null 避免重复处理 - } - return resultParamStatusCode; - }, - get responseHeaders() { - const responsed = headersReceivedMap.get(markerID); - if (responsed && responsed.responseHeaders) { - let s = ""; - for (const h of responsed.responseHeaders) { - s += `${h.name}: ${h.value}\n`; - } - resultParamResponseHeader = s; - responsed.responseHeaders = null; // 设为 null 避免重复处理 - } - return resultParamResponseHeader; - }, - get finalUrl() { - resultParamFinalUrl = redirectedUrls.get(markerID) || ""; - return resultParamFinalUrl; - }, - }; + const resultParam = new SWRequestResultParams(markerID); const throwErrorFn = (error: string) => { if (!isConnDisconnected) { @@ -793,23 +746,23 @@ export default class GMApi { return new Error(`${error}`); }; - const param1 = request.params[0]; - if (!param1) { + const details = request.params[0]; + if (!details) { throw throwErrorFn("param is failed"); } if (request.extraCode === xhrExtraCode.INVALID_URL) { - const msg = `Refused to connect to "${param1.url}": The url is invalid`; + const msg = `Refused to connect to "${details.url}": The url is invalid`; throw throwErrorFn(msg); } 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 "${param1.url}": 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); } if (request.extraCode === xhrExtraCode.DOMAIN_IN_BLACKLIST) { // 'Refused to connect to "https://example.org/": URL is blacklisted' - const msg = `Refused to connect to "${param1.url}": URL is blacklisted`; + const msg = `Refused to connect to "${details.url}": URL is blacklisted`; throw throwErrorFn(msg); } try { @@ -826,28 +779,28 @@ export default class GMApi { // https://github.com/scriptscat/scriptcat/commit/3774aa3acebeadb6b08162625a9af29a9599fa96 // cookiePartition shall refers to the following issue: // https://github.com/Tampermonkey/tampermonkey/issues/2419 - if (!param1.cookiePartition || typeof param1.cookiePartition !== "object") { - param1.cookiePartition = {}; + if (!details.cookiePartition || typeof details.cookiePartition !== "object") { + details.cookiePartition = {}; } - if (typeof param1.cookiePartition.topLevelSite !== "string") { + if (typeof details.cookiePartition.topLevelSite !== "string") { // string | undefined - param1.cookiePartition.topLevelSite = undefined; + details.cookiePartition.topLevelSite = undefined; } // 添加请求header, 处理unsafe hearder - await this.buildDNRRule(markerID, param1, sender); + await this.buildDNRRule(markerID, details, sender); // let finalUrl = ""; // 等待response let useFetch; { - const anonymous = param1.anonymous ?? param1.mozAnon ?? false; + const anonymous = details.anonymous ?? details.mozAnon ?? false; - const redirect = param1.redirect; + const redirect = details.redirect; - const isFetch = param1.fetch ?? false; + const isFetch = details.fetch ?? false; - const isBufferStream = param1.responseType === "stream"; + const isBufferStream = details.responseType === "stream"; useFetch = isFetch || !!redirect || anonymous || isBufferStream; } @@ -860,150 +813,49 @@ export default class GMApi { headersReceivedMap.delete(markerID); headerModifierMap.delete(markerID); }; - const requestUrl = param1.url; - - const f = async () => { - if (useFetch) { - // 只有fetch支持ReadableStream、redirect这些,直接使用fetch - bgXhrInterface( - param1, - { - get finalUrl() { - return resultParam.finalUrl; - }, - get responseHeaders() { - return resultParam.responseHeaders; - }, - get status() { - return resultParam.statusCode; - }, - loadendCleanUp() { - loadendCleanUp(); - }, - async 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; - }> - ) { - // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) - if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { - resultParamStatusCode = msg.data.status; - } - if (msg.data?.status === 301) { - // 兼容TM - redirect: manual 显示原网址 - redirectedUrls.delete(markerID); - resultParamFinalUrl = requestUrl; - msg.data.finalUrl = requestUrl; - } else if (msg.action === "onerror" && isRedirectError && msg.data) { - let nwErr = nwErrorResults.get(markerID); - if (!nwErr) { - // 等 Network Error 捕捉 - await Promise.race([ - new Promise((resolve) => { - nwErrorResultPromises.set(markerID, resolve); - }), - new Promise((r) => setTimeout(r, 800)), - ]); - nwErr = nwErrorResults.get(markerID); - } - if (nwErr) { - msg.data.status = 408; - msg.data.statusText = ""; - msg.data.responseHeaders = ""; - } - } - }, - }, - msgConn - ); - } else if (typeof XMLHttpRequest === "function") { - // No offscreen in Firefox, but Firefox background script itself provides XMLHttpRequest. - // Firefox 中没有 offscreen,但 Firefox 的"后台脚本"本身提供了 XMLHttpRequest。 - bgXhrInterface( - param1, - { - get finalUrl() { - return resultParam.finalUrl; - }, - get responseHeaders() { - return resultParam.responseHeaders; - }, - get status() { - return resultParam.statusCode; - }, - loadendCleanUp() { - loadendCleanUp(); - }, - async 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; - }> - ) { - // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) - if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { - resultParamStatusCode = msg.data.status; - } - }, - }, - msgConn - ); - } else { - // 再发送到offscreen, 处理请求 - const offscreenCon = await connect(this.msgSender, "offscreen/gmApi/xmlHttpRequest", param1); - offscreenCon.onMessage((msg) => { - // 发送到content - let data = msg.data; - // 修正 statusCode 在 接收responseHeader 后会变化的问题 (例如 401 -> 200) - if (msg.data?.status && resultParam.statusCode > 0 && resultParam.statusCode !== msg.data?.status) { - 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(); - }); - } - }; - - // 网络请求发出前。 - try { - await f(); - } catch (e: any) { - console.error(e); + 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"}`); 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/gm_api/xhr_interface.ts b/src/app/service/service_worker/gm_api/xhr_interface.ts deleted file mode 100644 index d4e847724..000000000 --- a/src/app/service/service_worker/gm_api/xhr_interface.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { stackAsyncTask } from "@App/pkg/utils/async_queue"; -import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/datatype"; -import { bgXhrRequestFn } from "@App/pkg/utils/xhr/xhr_bg_core"; -import { type MessageConnect, type TMessageCommAction } from "@Packages/message/types"; - -/** - * 把 bgXhrRequestFn 的执行结果通过 MessageConnect 进一步传到 service_worker / offscreen - * Communicate Network Request in Background - * @param param1 Input - * @param inRef Control - * @param msgConn Connection - */ -export const bgXhrInterface = (param1: any, inRef: any, msgConn: MessageConnect) => { - const taskId = `${Date.now}:${Math.random()}`; - let isConnDisconnected = false; - const settings = { - onDataReceived: (param: { chunk: boolean; type: string; data: any }) => { - stackAsyncTask(taskId, async () => { - if (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: {}, - }; - msgConn.sendMessage(msg); - } - for (const chunk of chunks) { - const msg: TMessageCommAction = { - action: `append_chunk_${param.type}`, - data: { - chunk: uint8ToBase64(chunk), - }, - }; - 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: {}, - }; - 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, - }, - }; - msgConn.sendMessage(msg); - } - } - } - } catch (e: any) { - console.error(e); - } - }); - }, - callback: ( - result: 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; - } - ) => { - const data = { - ...result, - finalUrl: inRef.finalUrl, - responseHeaders: inRef.responseHeaders || result.responseHeaders || "", - }; - const eventType = result.eventType; - const msg: TMessageCommAction = { - action: `on${eventType}`, - data: data, - }; - stackAsyncTask(taskId, async () => { - await inRef.fixMsg?.(msg); - if (eventType === "loadend") { - inRef.loadendCleanUp?.(); - } - if (isConnDisconnected) return; - msgConn.sendMessage(msg); - }); - }, - } as Record & { abort?: () => void }; - bgXhrRequestFn(param1, settings).catch((e: any) => { - settings.abort?.(); - console.error(e); - }); - msgConn.onDisconnect(() => { - isConnDisconnected = true; - settings.abort?.(); - // console.warn("msgConn.onDisconnect"); - }); -}; From 7a1ae302399458ede62051ba1e743aee706c37b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 14 Nov 2025 14:50:26 +0800 Subject: [PATCH 39/44] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/gm_xhr_test.js | 1292 ++++++++++++++++++++++++++++++++++ 1 file changed, 1292 insertions(+) create mode 100644 example/tests/gm_xhr_test.js 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); +})(); From de4113002dd879ae75d6314a3126ad45cd4bab5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 14 Nov 2025 15:42:06 +0800 Subject: [PATCH 40/44] =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/offscreen/gm_api.ts | 2 +- .../service_worker/gm_api/bg_gm_xhr.ts | 145 --- .../service/service_worker/gm_api/gm_api.ts | 2 +- src/pkg/utils/xhr/bg_gm_xhr.ts | 1006 +++++++++++++++++ src/pkg/utils/xhr/xhr_bg_core.ts | 610 ---------- src/types/main.d.ts | 2 + src/types/scriptcat.d.ts | 2 + 7 files changed, 1012 insertions(+), 757 deletions(-) delete mode 100644 src/app/service/service_worker/gm_api/bg_gm_xhr.ts create mode 100644 src/pkg/utils/xhr/bg_gm_xhr.ts diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts index dd3cb9675..b9ea11e8a 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -1,5 +1,5 @@ +import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; import type { IGetSender, Group } from "@Packages/message/server"; -import { BgGMXhr } from "../service_worker/gm_api/bg_gm_xhr"; export default class GMApi { constructor(private group: Group) {} diff --git a/src/app/service/service_worker/gm_api/bg_gm_xhr.ts b/src/app/service/service_worker/gm_api/bg_gm_xhr.ts deleted file mode 100644 index d2753b406..000000000 --- a/src/app/service/service_worker/gm_api/bg_gm_xhr.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { stackAsyncTask } from "@App/pkg/utils/async_queue"; -import { chunkUint8, uint8ToBase64 } from "@App/pkg/utils/datatype"; -import { bgXhrRequestFn } from "@App/pkg/utils/xhr/xhr_bg_core"; -import type { MessageConnect, TMessageCommAction } from "@Packages/message/types"; -import type { GMXhrStrategy } from "./gm_xhr"; - -export type RequestResultParams = { - statusCode: number; - responseHeaders: string; - finalUrl: string; -}; - -// 后台处理端 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 & { - // - 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; - } - ) { - 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; - } - - do() { - bgXhrRequestFn(this.details, { - onDataReceived: this.onDataReceived.bind(this), - callback: this.callback.bind(this), - }).catch((e: any) => { - // settings.abort?.(); - console.error(e); - }); - this.msgConn.onDisconnect(() => { - this.isConnDisconnected = true; - // settings.abort?.(); - // console.warn("msgConn.onDisconnect"); - }); - } -} diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index c1391d4da..4e279a862 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -42,7 +42,7 @@ import { SWRequestResultParams, } from "./gm_xhr"; import { headerModifierMap, headersReceivedMap } from "./gm_xhr"; -import { BgGMXhr } from "./bg_gm_xhr"; +import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; const askUnlistedConnect = false; const askConnectStar = true; 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..d31202089 --- /dev/null +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -0,0 +1,1006 @@ +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 { bgXhrRequestFn } from "@App/pkg/utils/xhr/xhr_bg_core"; +import type { MessageConnect, TMessageCommAction } from "@Packages/message/types"; +import { dataDecode } from "./xhr_data"; + +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. + */ + +/** + * Represents the response object returned to GM_xmlhttpRequest callbacks. + */ +export interface GMResponse { + /** The final URL after redirects */ + finalUrl: string; + /** Current ready state */ + readyState: number; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Raw response headers */ + responseHeaders: string; + /** Parsed response data (depends on responseType) */ + response: T; + /** Response as XML document (if applicable) */ + responseXML?: Document; + /** Response as plain text */ + responseText: string; + /** Context object passed from the request */ + context?: any; +} + +type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + +/** + * Represents the request details passed to GM_xmlhttpRequest. + */ +export interface XmlhttpRequestFnDetails { + /** HTTP method (GET, POST, PUT, DELETE, etc.) */ + method?: string; + /** Target url string */ + url: string; + /** Optional headers to include */ + headers?: Record; + /** Data to send with the request */ + data?: GMXHRDataType; + /** Redirect handling mode */ + redirect?: "follow" | "error" | "manual"; + /** Additional cookie to include */ + cookie?: string; + /** Partition key for partitioned cookies (v5.2+) */ + cookiePartition?: Record; + /** Top-level site for partitioned cookies */ + topLevelSite?: string; + /** Send data as binary */ + binary?: boolean; + /** Disable caching: don’t cache or store the resource at all */ + nocache?: boolean; + /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ + revalidate?: boolean; + /** Timeout in milliseconds */ + timeout?: number; + /** Custom value passed to response.context */ + context?: any; + /** Type of response expected */ + responseType?: "arraybuffer" | "blob" | "json" | "stream" | "" | "text" | "document"; // document for VM2.12.0+ + /** Override MIME type */ + overrideMimeType?: string; + /** Send request without cookies (Greasemonkey) */ + mozAnon?: boolean; + /** Send request without cookies */ + anonymous?: boolean; + /** Use fetch() instead of XMLHttpRequest */ + fetch?: boolean; + /** Username for authentication */ + user?: string; + /** Password for authentication */ + password?: string; + /** [NOT SUPPORTED] upload (Greasemonkey) */ + upload?: never; + /** [NOT SUPPORTED] synchronous (Greasemonkey) */ + synchronous?: never; + + /** Called if the request is aborted */ + onabort?: (response: GMResponse) => void; + /** Called on network error */ + onerror?: (response: GMResponse) => void; + /** Called when loading starts */ + onloadstart?: (response: GMResponse) => void; + /** Called on download progress */ + onprogress?: (response: GMResponse) => void; + /** Called when readyState changes */ + onreadystatechange?: (response: GMResponse) => void; + /** Called on request timeout */ + ontimeout?: (response: GMResponse) => void; + /** Called on successful request completion */ + onload?: (response: GMResponse) => void; +} + +/** + * The return value of GM_xmlhttpRequest — includes an abort() function. + */ +export interface GMRequestHandle { + /** Abort the ongoing request */ + abort: () => void; +} + +type ResponseType = "" | "text" | "json" | "blob" | "arraybuffer" | "document"; + +type ReadyState = + | 0 // UNSENT + | 1 // OPENED + | 2 // HEADERS_RECEIVED + | 3 // LOADING + | 4; // DONE + +interface ProgressLikeEvent { + loaded: number; + total: number; + lengthComputable: boolean; +} + +export class FetchXHR { + private readonly extraOptsFn: any; + private readonly isBufferStream: boolean; + private readonly onDataReceived: any; + constructor(opts: any) { + this.extraOptsFn = opts?.extraOptsFn ?? null; + this.isBufferStream = opts?.isBufferStream ?? false; + this.onDataReceived = opts?.onDataReceived ?? null; + // + } + + // 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: 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" }); + } +} + +// 后台处理端 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: 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); + + // console.log("rawData", rawData); + + const baseXHR = useFetch + ? new FetchXHR({ + extraOptsFn: (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"; + } + }, + isBufferStream, + onDataReceived: this.onDataReceived.bind(this), + }) + : 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 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 --- + // if ( + // rawData instanceof URLSearchParams || + // typeof rawData === "string" || + // rawData instanceof Blob || + // rawData instanceof FormData + // ) { + // requestInit.body = rawData as BodyInit; + // } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { + // // JSON body + // requestInit.body = JSON.stringify(rawData); + // if (!headers.has("Content-Type")) { + // headers.set("Content-Type", "application/json"); + // } + // } + + // // --- Handle cookies (if any) --- + // if (cookie) { + // requestInit.headers ||= {}; + // // if (!headers.has("Cookie")) { + // headers.set("Cookie", cookie); + // // } + // } + + // --- Handle request body --- + if ( + 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 + ) { + // + } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { + if ((baseXHR.getResponseHeader("Content-Type") || "application/json") !== "application/json") { + // JSON body + rawData = JSON.stringify(rawData); + baseXHR.setRequestHeader("Content-Type", "application/json"); + } 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/xhr_bg_core.ts b/src/pkg/utils/xhr/xhr_bg_core.ts index 20625bc29..3b3d946e1 100644 --- a/src/pkg/utils/xhr/xhr_bg_core.ts +++ b/src/pkg/utils/xhr/xhr_bg_core.ts @@ -1,613 +1,3 @@ -import { dataDecode } from "./xhr_data"; - -/** - * ## 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. - */ - -/** - * Represents the response object returned to GM_xmlhttpRequest callbacks. - */ -export interface GMResponse { - /** The final URL after redirects */ - finalUrl: string; - /** Current ready state */ - readyState: number; - /** HTTP status code */ - status: number; - /** HTTP status text */ - statusText: string; - /** Raw response headers */ - responseHeaders: string; - /** Parsed response data (depends on responseType) */ - response: T; - /** Response as XML document (if applicable) */ - responseXML?: Document; - /** Response as plain text */ - responseText: string; - /** Context object passed from the request */ - context?: any; -} - -type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; - -/** - * Represents the request details passed to GM_xmlhttpRequest. - */ -export interface XmlhttpRequestFnDetails { - /** HTTP method (GET, POST, PUT, DELETE, etc.) */ - method?: string; - /** Target url string */ - url: string; - /** Optional headers to include */ - headers?: Record; - /** Data to send with the request */ - data?: GMXHRDataType; - /** Redirect handling mode */ - redirect?: "follow" | "error" | "manual"; - /** Additional cookie to include */ - cookie?: string; - /** Partition key for partitioned cookies (v5.2+) */ - cookiePartition?: Record; - /** Top-level site for partitioned cookies */ - topLevelSite?: string; - /** Send data as binary */ - binary?: boolean; - /** Disable caching: don’t cache or store the resource at all */ - nocache?: boolean; - /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ - revalidate?: boolean; - /** Timeout in milliseconds */ - timeout?: number; - /** Custom value passed to response.context */ - context?: any; - /** Type of response expected */ - responseType?: "arraybuffer" | "blob" | "json" | "stream" | "" | "text" | "document"; // document for VM2.12.0+ - /** Override MIME type */ - overrideMimeType?: string; - /** Send request without cookies (Greasemonkey) */ - mozAnon?: boolean; - /** Send request without cookies */ - anonymous?: boolean; - /** Use fetch() instead of XMLHttpRequest */ - fetch?: boolean; - /** Username for authentication */ - user?: string; - /** Password for authentication */ - password?: string; - /** [NOT SUPPORTED] upload (Greasemonkey) */ - upload?: never; - /** [NOT SUPPORTED] synchronous (Greasemonkey) */ - synchronous?: never; - - /** Called if the request is aborted */ - onabort?: (response: GMResponse) => void; - /** Called on network error */ - onerror?: (response: GMResponse) => void; - /** Called when loading starts */ - onloadstart?: (response: GMResponse) => void; - /** Called on download progress */ - onprogress?: (response: GMResponse) => void; - /** Called when readyState changes */ - onreadystatechange?: (response: GMResponse) => void; - /** Called on request timeout */ - ontimeout?: (response: GMResponse) => void; - /** Called on successful request completion */ - onload?: (response: GMResponse) => void; -} - -/** - * The return value of GM_xmlhttpRequest — includes an abort() function. - */ -export interface GMRequestHandle { - /** Abort the ongoing request */ - abort: () => void; -} - -type ResponseType = "" | "text" | "json" | "blob" | "arraybuffer" | "document"; - -type ReadyState = - | 0 // UNSENT - | 1 // OPENED - | 2 // HEADERS_RECEIVED - | 3 // LOADING - | 4; // DONE - -interface ProgressLikeEvent { - loaded: number; - total: number; - lengthComputable: boolean; -} - -export class FetchXHR { - private readonly extraOptsFn: any; - private readonly isBufferStream: boolean; - private readonly onDataReceived: any; - constructor(opts: any) { - this.extraOptsFn = opts?.extraOptsFn ?? null; - this.isBufferStream = opts?.isBufferStream ?? false; - this.onDataReceived = opts?.onDataReceived ?? null; - // - } - - // 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: 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" }); - } -} /** * Greasemonkey/Tampermonkey GM_xmlhttpRequest API. diff --git a/src/types/main.d.ts b/src/types/main.d.ts index b46a485d1..ceae5a0a5 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -68,6 +68,8 @@ declare namespace GMSend { 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; diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 59f03c39b..281242bdf 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -506,6 +506,8 @@ declare namespace GMTypes { user?: string; password?: string; nocache?: boolean; + /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ + revalidate?: boolean; redirect?: "follow" | "error" | "manual"; // 为了与tm保持一致, 在v0.17.0后废弃maxRedirects, 使用redirect替代, 会强制使用fetch模式 onload?: Listener; From f19a4bef8cfa6dd48f885fffd50b195a23665401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 14 Nov 2025 15:57:15 +0800 Subject: [PATCH 41/44] =?UTF-8?q?=E6=95=B4=E7=90=86=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/xhr/bg_gm_xhr.ts | 126 --------------- src/pkg/utils/xhr/xhr_bg_core.ts | 268 ------------------------------- src/template/scriptcat.d.tpl | 49 ++++-- src/types/main.d.ts | 1 + src/types/scriptcat.d.ts | 12 +- 5 files changed, 43 insertions(+), 413 deletions(-) delete mode 100644 src/pkg/utils/xhr/xhr_bg_core.ts diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts index d31202089..4385833aa 100644 --- a/src/pkg/utils/xhr/bg_gm_xhr.ts +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -1,7 +1,6 @@ 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 { bgXhrRequestFn } from "@App/pkg/utils/xhr/xhr_bg_core"; import type { MessageConnect, TMessageCommAction } from "@Packages/message/types"; import { dataDecode } from "./xhr_data"; @@ -111,105 +110,6 @@ export type RequestResultParams = { * - You must declare appropriate `@connect` permissions in your userscript header. */ -/** - * Represents the response object returned to GM_xmlhttpRequest callbacks. - */ -export interface GMResponse { - /** The final URL after redirects */ - finalUrl: string; - /** Current ready state */ - readyState: number; - /** HTTP status code */ - status: number; - /** HTTP status text */ - statusText: string; - /** Raw response headers */ - responseHeaders: string; - /** Parsed response data (depends on responseType) */ - response: T; - /** Response as XML document (if applicable) */ - responseXML?: Document; - /** Response as plain text */ - responseText: string; - /** Context object passed from the request */ - context?: any; -} - -type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; - -/** - * Represents the request details passed to GM_xmlhttpRequest. - */ -export interface XmlhttpRequestFnDetails { - /** HTTP method (GET, POST, PUT, DELETE, etc.) */ - method?: string; - /** Target url string */ - url: string; - /** Optional headers to include */ - headers?: Record; - /** Data to send with the request */ - data?: GMXHRDataType; - /** Redirect handling mode */ - redirect?: "follow" | "error" | "manual"; - /** Additional cookie to include */ - cookie?: string; - /** Partition key for partitioned cookies (v5.2+) */ - cookiePartition?: Record; - /** Top-level site for partitioned cookies */ - topLevelSite?: string; - /** Send data as binary */ - binary?: boolean; - /** Disable caching: don’t cache or store the resource at all */ - nocache?: boolean; - /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ - revalidate?: boolean; - /** Timeout in milliseconds */ - timeout?: number; - /** Custom value passed to response.context */ - context?: any; - /** Type of response expected */ - responseType?: "arraybuffer" | "blob" | "json" | "stream" | "" | "text" | "document"; // document for VM2.12.0+ - /** Override MIME type */ - overrideMimeType?: string; - /** Send request without cookies (Greasemonkey) */ - mozAnon?: boolean; - /** Send request without cookies */ - anonymous?: boolean; - /** Use fetch() instead of XMLHttpRequest */ - fetch?: boolean; - /** Username for authentication */ - user?: string; - /** Password for authentication */ - password?: string; - /** [NOT SUPPORTED] upload (Greasemonkey) */ - upload?: never; - /** [NOT SUPPORTED] synchronous (Greasemonkey) */ - synchronous?: never; - - /** Called if the request is aborted */ - onabort?: (response: GMResponse) => void; - /** Called on network error */ - onerror?: (response: GMResponse) => void; - /** Called when loading starts */ - onloadstart?: (response: GMResponse) => void; - /** Called on download progress */ - onprogress?: (response: GMResponse) => void; - /** Called when readyState changes */ - onreadystatechange?: (response: GMResponse) => void; - /** Called on request timeout */ - ontimeout?: (response: GMResponse) => void; - /** Called on successful request completion */ - onload?: (response: GMResponse) => void; -} - -/** - * The return value of GM_xmlhttpRequest — includes an abort() function. - */ -export interface GMRequestHandle { - /** Abort the ongoing request */ - abort: () => void; -} - type ResponseType = "" | "text" | "json" | "blob" | "arraybuffer" | "document"; type ReadyState = @@ -762,8 +662,6 @@ export class BgGMXhr { const prepareXHR = async () => { let rawData = (details.data = await details.data); - // console.log("rawData", rawData); - const baseXHR = useFetch ? new FetchXHR({ extraOptsFn: (opts: RequestInit) => { @@ -932,30 +830,6 @@ export class BgGMXhr { baseXHR.setRequestHeader("Cache-Control", "max-age=0, must-revalidate"); } - // // --- Handle request body --- - // if ( - // rawData instanceof URLSearchParams || - // typeof rawData === "string" || - // rawData instanceof Blob || - // rawData instanceof FormData - // ) { - // requestInit.body = rawData as BodyInit; - // } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { - // // JSON body - // requestInit.body = JSON.stringify(rawData); - // if (!headers.has("Content-Type")) { - // headers.set("Content-Type", "application/json"); - // } - // } - - // // --- Handle cookies (if any) --- - // if (cookie) { - // requestInit.headers ||= {}; - // // if (!headers.has("Cookie")) { - // headers.set("Cookie", cookie); - // // } - // } - // --- Handle request body --- if ( rawData instanceof URLSearchParams || diff --git a/src/pkg/utils/xhr/xhr_bg_core.ts b/src/pkg/utils/xhr/xhr_bg_core.ts deleted file mode 100644 index 3b3d946e1..000000000 --- a/src/pkg/utils/xhr/xhr_bg_core.ts +++ /dev/null @@ -1,268 +0,0 @@ - -/** - * Greasemonkey/Tampermonkey GM_xmlhttpRequest API. - * @example - * GM_xmlhttpRequest({ - * method: 'GET', - * url: 'https://example.com/', - * onload: (res) => console.log(res.responseText), - * }); - */ - -/** - * 在后台实际进行 xhr / fetch 的操作 - * Network Request in Background - * 只接受 "", "text", "arraybuffer", 及 "stream" - * @param details Input - * @param settings Control - */ -export const bgXhrRequestFn = async (details: XmlhttpRequestFnDetails, settings: any) => { - 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); - - // console.log("rawData", rawData); - - const baseXHR = useFetch - ? new FetchXHR({ - extraOptsFn: (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"; - } - }, - isBufferStream, - onDataReceived: settings.onDataReceived, - }) - : new XMLHttpRequest(); - - settings.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") { - settings.onDataReceived({ chunk: false, type: "text", data: xhr.responseText }); - } else if (xhrResponseType === "arraybuffer" && response instanceof ArrayBuffer) { - settings.onDataReceived({ chunk: false, type: "arraybuffer", data: response }); - } - } - } - settings.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, - // 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 --- - // if ( - // rawData instanceof URLSearchParams || - // typeof rawData === "string" || - // rawData instanceof Blob || - // rawData instanceof FormData - // ) { - // requestInit.body = rawData as BodyInit; - // } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { - // // JSON body - // requestInit.body = JSON.stringify(rawData); - // if (!headers.has("Content-Type")) { - // headers.set("Content-Type", "application/json"); - // } - // } - - // // --- Handle cookies (if any) --- - // if (cookie) { - // requestInit.headers ||= {}; - // // if (!headers.has("Cookie")) { - // headers.set("Cookie", cookie); - // // } - // } - - // --- Handle request body --- - if ( - 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 - ) { - // - } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { - if ((baseXHR.getResponseHeader("Content-Type") || "application/json") !== "application/json") { - // JSON body - rawData = JSON.stringify(rawData); - baseXHR.setRequestHeader("Content-Type", "application/json"); - } 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(); -}; diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index 8e7bf4043..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 | File | Blob; + 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 ceae5a0a5..6d1c6760e 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -63,6 +63,7 @@ declare namespace GMSend { responseType?: "" | "text" | "arraybuffer" | "blob" | "json" | "document" | "stream"; overrideMimeType?: string; anonymous?: boolean; + /** Send request without cookies (Greasemonkey) */ mozAnon?: boolean; fetch?: boolean; user?: string; diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 281242bdf..8aa55a2df 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -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 | File | Blob; headers?: { [key: string]: string }; - data?: string | FormData | Blob; + data?: GMXHRDataType; cookie?: string; binary?: boolean; timeout?: number; @@ -502,13 +504,17 @@ 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; - /** Force revalidation of cached content: may cache, but must revalidate before using cached content */ - revalidate?: 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; From aa3cfd90c7b810b2beb9cd332f8cbaed8c66b77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 14 Nov 2025 17:02:41 +0800 Subject: [PATCH 42/44] =?UTF-8?q?=E6=95=B4=E7=90=86fetch=20xhr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/xhr/bg_gm_xhr.ts | 447 ++------------------------------- src/pkg/utils/xhr/fetch_xhr.ts | 398 +++++++++++++++++++++++++++++ src/types/scriptcat.d.ts | 9 +- 3 files changed, 423 insertions(+), 431 deletions(-) create mode 100644 src/pkg/utils/xhr/fetch_xhr.ts diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts index 4385833aa..c886ae536 100644 --- a/src/pkg/utils/xhr/bg_gm_xhr.ts +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -3,6 +3,7 @@ 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; @@ -110,416 +111,6 @@ export type RequestResultParams = { * - You must declare appropriate `@connect` permissions in your userscript header. */ -type ResponseType = "" | "text" | "json" | "blob" | "arraybuffer" | "document"; - -type ReadyState = - | 0 // UNSENT - | 1 // OPENED - | 2 // HEADERS_RECEIVED - | 3 // LOADING - | 4; // DONE - -interface ProgressLikeEvent { - loaded: number; - total: number; - lengthComputable: boolean; -} - -export class FetchXHR { - private readonly extraOptsFn: any; - private readonly isBufferStream: boolean; - private readonly onDataReceived: any; - constructor(opts: any) { - this.extraOptsFn = opts?.extraOptsFn ?? null; - this.isBufferStream = opts?.isBufferStream ?? false; - this.onDataReceived = opts?.onDataReceived ?? null; - // - } - - // 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: 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" }); - } -} - // 后台处理端 GM Xhr 实现 export class BgGMXhr { private taskId: string; @@ -599,7 +190,7 @@ export class BgGMXhr { callback( result: Record & { // - readyState: ReadyState; + readyState: GMTypes.ReadyState; status: number; statusText: string; responseHeaders: string | null; @@ -663,24 +254,20 @@ export class BgGMXhr { let rawData = (details.data = await details.data); const baseXHR = useFetch - ? new FetchXHR({ - extraOptsFn: (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"; - } - }, - isBufferStream, - onDataReceived: this.onDataReceived.bind(this), + ? 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(); @@ -753,7 +340,7 @@ export class BgGMXhr { ok: xhr.status >= 200 && xhr.status < 300, contentType, // Always - readyState: xhr.readyState as ReadyState, + readyState: xhr.readyState as GMTypes.ReadyState, // After response headers status: xhr.status, statusText: xhr.statusText, 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/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 8aa55a2df..35c2f0f99 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -466,9 +466,16 @@ 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; From 6d67d1dae52ac1abdce9aa7eedd0c8d2caaea092 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:59:00 +0900 Subject: [PATCH 43/44] =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/xhr/bg_gm_xhr.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts index c886ae536..d7a2b8b00 100644 --- a/src/pkg/utils/xhr/bg_gm_xhr.ts +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -418,7 +418,8 @@ export class BgGMXhr { } // --- Handle request body --- - if ( + // 标准 xhr request 的 body 类型: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/send + const isStandardRequestBody = rawData instanceof URLSearchParams || typeof rawData === "string" || typeof rawData === "number" || @@ -428,14 +429,17 @@ export class BgGMXhr { rawData instanceof Blob || rawData instanceof FormData || rawData instanceof ArrayBuffer || - rawData instanceof Uint8Array - ) { - // - } else if (rawData && typeof rawData === "object" && !(rawData instanceof ArrayBuffer)) { + rawData instanceof Uint8Array; + // 其他标准以外的物件类型则尝试 JSON 转换 + if (!isStandardRequestBody && typeof rawData === "object") { if ((baseXHR.getResponseHeader("Content-Type") || "application/json") !== "application/json") { // JSON body - rawData = JSON.stringify(rawData); - baseXHR.setRequestHeader("Content-Type", "application/json"); + try { + rawData = JSON.stringify(rawData); + baseXHR.setRequestHeader("Content-Type", "application/json"); + } catch { + rawData = undefined; + } } else { rawData = undefined; } From 79c433481b9eaa7a33aa6b27dc6204fe87569c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 15 Nov 2025 11:15:37 +0800 Subject: [PATCH 44/44] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B8=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/utils.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 8b6d040c8..d4b62df4b 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -369,11 +369,3 @@ export const stringMatching = (main: string, sub: string): boolean => { return false; } }; - -export const urlSanitize = (url: string) => { - const u = new URL(url); // 利用 URL 处理 URL Encoding 问题。 - // 例如 'https://日月.baidu.com/你好' => 'https://xn--wgv4y.baidu.com/%E4%BD%A0%E5%A5%BD' - // 为方便控制,只需要考虑 orign 和 pathname 的匹对 - // https://user:passwd@httpbun.com/basic-auth/user/passwd -> https://httpbun.com/basic-auth/user/passwd - return `URL::${u.origin}${u.pathname}`; -};