diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index ea445e6ec..aefa23166 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -2,11 +2,10 @@ import type { TScriptInfo } from "@App/app/repo/scripts"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { Message } from "@Packages/message/types"; import EventEmitter from "eventemitter3"; -import { GMContextApiGet } from "./gm_api/gm_context"; import { protect } from "./gm_api/gm_context"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; -import { createGMBase } from "./gm_api/gm_api"; +import { createGMBase, createGMApis } from "./gm_api/gm_api"; // 构建沙盒上下文 export const createContext = ( @@ -27,8 +26,7 @@ export const createContext = ( loadScriptResolve = resolve; }); } - let invalid = false; - const context = createGMBase({ + const gtx = createGMBase({ prefix: envPrefix, message, scriptRes, @@ -41,57 +39,22 @@ export const createContext = ( window: { // onurlchange: null, }, - grantSet: new Set(), loadScriptPromise, loadScriptResolve, - setInvalidContext() { - if (invalid) return; - invalid = true; - this.valueChangeListener.clear(); - this.EE.removeAllListeners(); - this.runFlag = `${uuidv4()}(invalid)`; // 更改 uuid 防止 runFlag 相关操作 - // 释放记忆 - this.message = null; - this.scriptRes = null; - this.valueChangeListener = null; - this.EE = null; - }, - isInvalidContext() { - return invalid; - }, }); + const gmApis = createGMApis(gtx, scriptGrants); const grantedAPIs: { [key: string]: any } = {}; - const __methodInject__ = (grant: string): boolean => { - const grantSet: Set = context.grantSet; - const s = GMContextApiGet(grant); - if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) - if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) - grantSet.add(grant); - for (const { fnKey, api, param } of s) { - grantedAPIs[fnKey] = api.bind(context); - const depend = param?.depend; - if (depend) { - for (const grant of depend) { - __methodInject__(grant); - } - } - } - return true; - }; - for (const grant of scriptGrants) { - // GM. 与 GM_ 都需要注入 - __methodInject__(grant); - if (grant.startsWith("GM.")) { - __methodInject__(grant.replace("GM.", "GM_")); - } else if (grant.startsWith("GM_")) { - __methodInject__(grant.replace("GM_", "GM.")); - } + + for (const [key, value] of Object.entries(gmApis)) { + (gmApis as any)[key] = undefined; // 释放不需要的函数 + if (key[0] === "_" || typeof value !== "function") continue; + grantedAPIs[key] = value; } // 兼容GM.Cookie.* for (const fnKey of Object.keys(grantedAPIs)) { const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; - let g = context; + let g = gtx; let s = ""; for (let i = 0; i < m; i++) { const part = fnKeyArray[i]; @@ -99,8 +62,8 @@ export const createContext = ( g = g[part] || (g[part] = grantedAPIs[s] || {}); } } - context.unsafeWindow = window; - return context; + gtx.unsafeWindow = window; + return gtx; }; const noEval = false; diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index 3a7b61e59..dfbb52bf2 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -33,6 +33,7 @@ const envInfo: GMInfoEnv = { describe.concurrent("@grant GM", () => { it.concurrent("GM_", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = [ "GM_getValue", "GM_getTab", @@ -67,23 +68,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret.GM_getValue?.name).toEqual("bound GM_getValue"); + expect(ret.GM_getValue?.name).toEqual("GM_getValue"); // getTab / getTabs / saveTab - expect(ret.GM_getTab?.name).toEqual("bound GM_getTab"); - expect(ret.GM_getTabs?.name).toEqual("bound GM_getTabs"); - expect(ret.GM_saveTab?.name).toEqual("bound GM_saveTab"); + expect(ret.GM_getTab?.name).toEqual("GM_getTab"); + expect(ret.GM_getTabs?.name).toEqual("GM_getTabs"); + expect(ret.GM_saveTab?.name).toEqual("GM_saveTab"); // cookie - expect(ret.GM_cookie?.name).toEqual("bound GM_cookie"); - expect(ret["GM_cookie.list"]?.name).toEqual("bound GM_cookie.list"); + expect(ret.GM_cookie?.name).toEqual("GM_cookie"); + expect(ret["GM_cookie.list"]?.name).toEqual("GM_cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -91,6 +92,7 @@ describe.concurrent("@grant GM", () => { it.concurrent("GM.*", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = [ "GM.getValue", "GM.getTab", @@ -124,23 +126,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret["GM.getValue"]?.name).toEqual("bound GM.getValue"); + expect(ret["GM.getValue"]?.name).toEqual("GM.getValue"); // getTab / getTabs / saveTab - expect(ret["GM.getTab"]?.name).toEqual("bound GM.getTab"); - expect(ret["GM.getTabs"]?.name).toEqual("bound GM.getTabs"); - expect(ret["GM.saveTab"]?.name).toEqual("bound GM.saveTab"); + expect(ret["GM.getTab"]?.name).toEqual("GM.getTab"); + expect(ret["GM.getTabs"]?.name).toEqual("GM.getTabs"); + expect(ret["GM.saveTab"]?.name).toEqual("GM.saveTab"); // cookie - expect(ret["GM.cookie"]?.name).toEqual("bound GM.cookie"); - expect(ret["GM.cookie"]?.list?.name).toEqual("bound GM.cookie.list"); + expect(ret["GM.cookie"]?.name).toEqual("GM.cookie"); + expect(ret["GM.cookie"]?.list?.name).toEqual("GM.cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -150,6 +152,7 @@ describe.concurrent("@grant GM", () => { describe.concurrent("window.*", () => { it.concurrent("window.close", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["window.close"]; script.code = `return window.close;`; // @ts-ignore @@ -164,6 +167,7 @@ describe.concurrent("GM Api", () => { it.concurrent("GM_getValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test: "ok" }; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValue"]; script.code = `return GM_getValue("test");`; // @ts-ignore @@ -175,6 +179,7 @@ describe.concurrent("GM Api", () => { it.concurrent("GM.getValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test: "ok" }; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM.getValue"]; script.code = `return GM.getValue("test").then(v=>v+"!");`; // @ts-ignore @@ -187,6 +192,7 @@ describe.concurrent("GM Api", () => { it.concurrent("GM_listValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test1: "23", test2: "45", test3: "67" }; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_listValues"]; script.code = `return GM_listValues().join("-");`; // @ts-ignore @@ -203,6 +209,7 @@ describe.concurrent("GM Api", () => { script.value.test2 = "70"; script.value.test3 = "75"; script.value.test1 = "40"; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_listValues"]; script.code = `return GM_listValues().join("-");`; // @ts-ignore @@ -215,6 +222,7 @@ describe.concurrent("GM Api", () => { it.concurrent("GM.listValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test1: "23", test2: "45", test3: "67" }; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM.listValues"]; script.code = `return GM.listValues().then(v=>v.join("-"));`; // @ts-ignore @@ -231,6 +239,7 @@ describe.concurrent("GM Api", () => { script.value.test2 = "70"; script.value.test3 = "75"; script.value.test1 = "40"; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM.listValues"]; script.code = `return GM.listValues().then(v=>v.join("-"));`; // @ts-ignore @@ -243,6 +252,7 @@ describe.concurrent("GM Api", () => { it.concurrent("GM_getValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test1: "23", test2: 45, test3: "67" }; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValues"]; script.code = `return GM_getValues(["test2", "test3", "test1"]);`; // @ts-ignore @@ -264,6 +274,7 @@ describe.concurrent("GM Api", () => { it.concurrent("GM.getValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = { test1: "23", test2: 45, test3: "67" }; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM.getValues"]; script.code = `return GM.getValues(["test2", "test3", "test1"]).then(v=>v);`; // @ts-ignore @@ -309,6 +320,7 @@ describe.concurrent("early-script", () => { describe.concurrent("GM_menu", () => { it.concurrent("注册菜单", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_registerMenuCommand"]; script.code = `return new Promise(resolve=>{ GM_registerMenuCommand("test", ()=>resolve(123)); @@ -357,6 +369,7 @@ describe.concurrent("GM_menu", () => { it.concurrent("取消注册菜单", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_registerMenuCommand", "GM_unregisterMenuCommand"]; script.code = ` let key = GM_registerMenuCommand("test", ()=>key="test"); @@ -380,6 +393,7 @@ describe.concurrent("GM_menu", () => { it.concurrent("同id菜单,执行最后一个", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_registerMenuCommand"]; script.code = `return new Promise(resolve=>{ GM_registerMenuCommand("duplicate-menu-id", ()=>resolve(123),{id: "abc"}); @@ -431,6 +445,7 @@ describe.concurrent("GM_menu", () => { it.concurrent("id生成逻辑", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_registerMenuCommand"]; script.code = ` // 自定义id @@ -463,6 +478,7 @@ describe.concurrent("GM_menu", () => { describe.concurrent("GM_value", () => { it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValue", "GM_setValue"]; script.code = ` GM_setValue("a", 123); @@ -556,6 +572,7 @@ describe.concurrent("GM_value", () => { it.concurrent("value引用问题 #1141", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.value = {}; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_getValues"]; script.code = ` const value1 = { @@ -708,6 +725,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValues", "GM_setValues"]; script.code = ` GM_setValues({"a":123,"b":456,"c":"789"}); @@ -836,6 +854,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 it.concurrent("GM_deleteValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValues", "GM_setValues", "GM_deleteValue"]; script.code = ` GM_setValues({"a":123,"b":456,"c":"789"}); @@ -905,6 +924,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 it.concurrent("GM_deleteValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValues", "GM_setValues", "GM_deleteValues"]; script.code = ` GM_setValues({"a":123,"b":456,"c":"789"}); @@ -980,6 +1000,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 it.concurrent("GM_addValueChangeListener - remote: false", async () => { const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_addValueChangeListener"]; script.metadata.storageName = ["testStorage"]; script.code = ` @@ -1014,6 +1035,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 it.concurrent("GM_addValueChangeListener - remote: true", async () => { const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_addValueChangeListener"]; script.metadata.storageName = ["testStorage"]; script.code = ` @@ -1048,6 +1070,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); it.concurrent("异步GM.setValue,等待回调", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata = Object.assign({}, script.metadata); script.metadata.grant = ["GM.getValue", "GM.setValue"]; script.code = `await GM.setValue("a", 123); return await GM.getValue("a");`; const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 874c4c1ff..6be5a670b 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -13,14 +13,14 @@ import type { } 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 type EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; import { type ScriptRunResource } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { isContent } from "@Packages/message/common"; import { getStorageName } from "@App/pkg/utils/utils"; -import { ListenerManager } from "../listener_manager"; +import { type ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; @@ -39,6 +39,8 @@ export interface GMRequestHandle { abort: () => void; } +const hasGrant = (grants: Set, ...list: string[]) => list.some((grant) => grants.has(grant)); + const integrity = {}; // 仅防止非法实例化 let valChangeCounterId = 0; @@ -47,7 +49,7 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap = new Map(); -const execEnvInit = (execEnv: GMApi) => { +const execEnvInit = (execEnv: GM_Base) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 execEnv.menuKeyRegistered = new Set(); @@ -58,35 +60,32 @@ const execEnvInit = (execEnv: GMApi) => { // GM_Base 定义内部用变量和函数。均使用@protected // 暂不考虑 Object.getOwnPropertyNames(GM_Base.prototype) 和 ts-morph 脚本生成 -class GM_Base implements IGM_Base { +export class GM_Base implements IGM_Base { @GMContext.protected() protected runFlag!: string; @GMContext.protected() - protected prefix!: string; + public prefix!: string; // Extension Context 无效时释放 scriptRes @GMContext.protected() - protected message?: Message | null; + public message?: Message | null; // Extension Context 无效时释放 scriptRes @GMContext.protected() - protected scriptRes?: ScriptRunResource | null; + public scriptRes?: ScriptRunResource | null; // Extension Context 无效时释放 valueChangeListener @GMContext.protected() - protected valueChangeListener?: ListenerManager; + public valueChangeListener?: ListenerManager | null; // Extension Context 无效时释放 EE @GMContext.protected() - protected EE?: EventEmitter | null; + public EE?: EventEmitter | null; @GMContext.protected() public context!: any; - @GMContext.protected() - public grantSet!: any; - @GMContext.protected() public eventId!: number; @@ -94,7 +93,7 @@ class GM_Base implements IGM_Base { protected loadScriptResolve: (() => void) | undefined; @GMContext.protected() - protected loadScriptPromise: Promise | undefined; + public loadScriptPromise: Promise | undefined; constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); @@ -189,1313 +188,1332 @@ class GM_Base implements IGM_Base { if (!this.EE) return; this.EE.emit(`${event}:${eventId}`, data); } -} -// GMApi 定义 外部用API函数。不使用@protected -export default class GMApi extends GM_Base { /** * */ notificationTagMap?: Map; - constructor( - public prefix: string, - public message: Message | undefined, - public scriptRes: ScriptRunResource | undefined - ) { - // testing only 仅供测试用 - const valueChangeListener = new ListenerManager(); - const EE = new EventEmitter(); - let invalid = false; - super( - { - prefix, - message, - scriptRes, - valueChangeListener, - EE, - notificationTagMap: new Map(), - eventId: 0, - setInvalidContext() { - if (invalid) return; - invalid = true; - this.valueChangeListener.clear(); - this.EE.removeAllListeners(); - // 释放记忆 - this.message = null; - this.scriptRes = null; - this.valueChangeListener = null; - this.EE = null; - }, - isInvalidContext() { - return invalid; - }, - }, - integrity - ); - } + // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 + // 唯一键格式:{contentEnvKey}.t{注册ID},由 execEnvInit() 建立/维护。 + menuKeyRegistered: Set | undefined; - static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { - if (!a.scriptRes) return undefined; - const ret = a.scriptRes.value[key]; - if (ret !== undefined) { - if (ret && typeof ret === "object") { - return customClone(ret)!; - } - return ret; - } - return defaultValue; - } + // 自动产生的菜单 ID 累计器(仅在未提供 options.id 时使用)。 + // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 + menuIdCounter: number | undefined; - // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 - @GMContext.API() - public GM_getValue(key: string, defaultValue?: any) { - return _GM_getValue(this, key, defaultValue); - } + // 菜单注册累计器 - 用于稳定同一Tab不同frame之选项的单独项目不合并状态 + // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 + regMenuCounter: number | undefined; - @GMContext.API() - public "GM.getValue"(key: string, defaultValue?: any): Promise { - // 兼容GM.getValue - return new Promise((resolve) => { - const ret = _GM_getValue(this, key, defaultValue); - resolve(ret); - }); - } + // 内容脚本执行环境识别符,用于区分 mainframe / subframe 等环境并作为 menu key 的命名空间。 + // 由 execEnvInit() 以 randomMessageFlag() 生成,避免跨 frame 的 ID 碰撞。 + // (同一环境跨脚本也不一样) + contentEnvKey: string | undefined; +} - static _GM_setValue(a: GMApi, promise: any, key: string, value: any) { - if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; - if (promise) { - valueChangePromiseMap.set(id, promise); - } - if (value === undefined) { - delete a.scriptRes.value[key]; - a.sendMessage("GM_setValue", [id, key]); - } else { - // 对object的value进行一次转化 - if (value && typeof value === "object") { - value = customClone(value); +export const createGMApis = (gtx: GM_Base, scriptGrants: Set) => { + let invalid = false; + + gtx.setInvalidContext = () => { + if (invalid) return; + invalid = true; + gtx.valueChangeListener?.clear(); + gtx.EE?.removeAllListeners(); + // 释放记忆 + gtx.message = null; + gtx.scriptRes = null; + gtx.valueChangeListener = null; + gtx.EE = null; + }; + gtx.isInvalidContext = () => { + return invalid; + }; + + // API 定義 + + let apis; + + const { + // 在这里抽出代码,打包时压缩名字 + _GM_getValue, + GM_getValues, + _GM_setValue, + _GM_setValues, + _GM_registerMenuCommand, + _GM_unregisterMenuCommand, + GM_log, + GM_addStyle, + GM_addElement, + GM_openInTab, + GM_saveTab, + GM_getTab, + GM_getTabs, + GM_getResourceText, + GM_getResourceURL, + GM_setClipboard, + _GM_download, + _GM_notification, + GM_cookie, + } = (apis = { + // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 + _GM_getValue(key: string, defaultValue?: any) { + if (!gtx.scriptRes) return undefined; + const ret = gtx.scriptRes.value[key]; + if (ret !== undefined) { + if (ret && typeof ret === "object") { + return customClone(ret)!; + } + return ret; } - // customClone 可能返回 undefined - a.scriptRes.value[key] = value; - if (value === undefined) { - a.sendMessage("GM_setValue", [id, key]); - } else { - a.sendMessage("GM_setValue", [id, key, value]); + return defaultValue; + }, + ...(hasGrant(scriptGrants, "GM.getValue", "GM_getValue") && { + GM_getValue(key: string, defaultValue?: any) { + return _GM_getValue(key, defaultValue); + }, + "GM.getValue"(key: string, defaultValue?: any): Promise { + // 兼容GM.getValue + return new Promise((resolve) => { + const ret = _GM_getValue(key, defaultValue); + resolve(ret); + }); + }, + }), + + _GM_setValue(promise: any, key: string, value: any) { + if (!gtx.scriptRes) return; + if (valChangeCounterId > 1e8) { + // 防止 valChangeCounterId 过大导致无法正常工作 + valChangeCounterId = 0; + valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; } - } - return id; - } - - static _GM_setValues(a: GMApi, promise: any, values: TGMKeyValue) { - if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; - if (promise) { - valueChangePromiseMap.set(id, promise); - } - const valueStore = a.scriptRes.value; - const keyValuePairs = [] as [string, REncoded][]; - for (const [key, value] of Object.entries(values)) { - let value_ = value; - if (value_ === undefined) { - if (valueStore[key]) delete valueStore[key]; + const id = `${valChangeRandomId}::${++valChangeCounterId}`; + if (promise) { + valueChangePromiseMap.set(id, promise); + } + if (value === undefined) { + delete gtx.scriptRes.value[key]; + gtx.sendMessage("GM_setValue", [id, key]); } else { // 对object的value进行一次转化 - if (value_ && typeof value_ === "object") { - value_ = customClone(value_); + if (value && typeof value === "object") { + value = customClone(value); } // customClone 可能返回 undefined - valueStore[key] = value_; + gtx.scriptRes.value[key] = value; + if (value === undefined) { + gtx.sendMessage("GM_setValue", [id, key]); + } else { + gtx.sendMessage("GM_setValue", [id, key, value]); + } } - // 避免undefined 等空值流失,先进行映射处理 - keyValuePairs.push([key, encodeRValue(value_)]); - } - a.sendMessage("GM_setValues", [id, keyValuePairs]); - return id; - } + return id; + }, + + _GM_setValues(promise: any, values: TGMKeyValue) { + if (!gtx.scriptRes) return; + if (valChangeCounterId > 1e8) { + // 防止 valChangeCounterId 过大导致无法正常工作 + valChangeCounterId = 0; + valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; + } + const id = `${valChangeRandomId}::${++valChangeCounterId}`; + if (promise) { + valueChangePromiseMap.set(id, promise); + } + const valueStore = gtx.scriptRes.value; + const keyValuePairs = [] as [string, REncoded][]; + for (const [key, value] of Object.entries(values)) { + let value_ = value; + if (value_ === undefined) { + if (valueStore[key]) delete valueStore[key]; + } else { + // 对object的value进行一次转化 + if (value_ && typeof value_ === "object") { + value_ = customClone(value_); + } + // customClone 可能返回 undefined + valueStore[key] = value_; + } + // 避免undefined 等空值流失,先进行映射处理 + keyValuePairs.push([key, encodeRValue(value_)]); + } + gtx.sendMessage("GM_setValues", [id, keyValuePairs]); + return id; + }, - @GMContext.API() - public GM_setValue(key: string, value: any) { - _GM_setValue(this, null, key, value); - } + ...(hasGrant(scriptGrants, "GM.setValue", "GM_setValue") && { + GM_setValue(key: string, value: any) { + _GM_setValue(null, key, value); + }, - @GMContext.API() - public "GM.setValue"(key: string, value: any): Promise { - // Asynchronous wrapper for GM_setValue to support GM.setValue - return new Promise((resolve) => { - _GM_setValue(this, resolve, key, value); - }); - } + "GM.setValue"(key: string, value: any): Promise { + // Asynchronous wrapper for GM_setValue to support GM.setValue + return new Promise((resolve) => { + _GM_setValue(resolve, key, value); + }); + }, + }), - @GMContext.API() - public GM_deleteValue(key: string): void { - _GM_setValue(this, null, key, undefined); - } + ...(hasGrant(scriptGrants, "GM.deleteValue", "GM_deleteValue") && { + GM_deleteValue(key: string): void { + _GM_setValue(null, key, undefined); + }, - @GMContext.API() - public "GM.deleteValue"(key: string): Promise { - // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue - return new Promise((resolve) => { - _GM_setValue(this, resolve, key, undefined); - }); - } + "GM.deleteValue"(key: string): Promise { + // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue + return new Promise((resolve) => { + _GM_setValue(resolve, key, undefined); + }); + }, + }), - @GMContext.API() - public GM_listValues(): string[] { - if (!this.scriptRes) return []; - const keys = Object.keys(this.scriptRes.value); - return keys; - } + ...(hasGrant(scriptGrants, "GM.listValues", "GM_listValues") && { + GM_listValues(): string[] { + if (!gtx.scriptRes) return []; + const keys = Object.keys(gtx.scriptRes.value); + return keys; + }, - @GMContext.API() - public "GM.listValues"(): Promise { - // Asynchronous wrapper for GM_listValues to support GM.listValues - return new Promise((resolve) => { - if (!this.scriptRes) return resolve([]); - const keys = Object.keys(this.scriptRes.value); - resolve(keys); - }); - } + "GM.listValues"(): Promise { + // Asynchronous wrapper for GM_listValues to support GM.listValues + return new Promise((resolve) => { + if (!gtx.scriptRes) return resolve([]); + const keys = Object.keys(gtx.scriptRes.value); + resolve(keys); + }); + }, + }), - @GMContext.API() - public GM_setValues(values: TGMKeyValue) { - if (!values || typeof values !== "object") { - throw new Error("GM_setValues: values must be an object"); - } - _GM_setValues(this, null, values); - } + ...(hasGrant(scriptGrants, "GM.setValues", "GM_setValues") && { + GM_setValues(values: TGMKeyValue) { + if (!values || typeof values !== "object") { + throw new Error("GM_setValues: values must be an object"); + } + _GM_setValues(null, values); + }, - @GMContext.API() - public GM_getValues(keysOrDefaults: TGMKeyValue | string[] | null | undefined) { - if (!this.scriptRes) return {}; - if (!keysOrDefaults) { - // Returns all values - return customClone(this.scriptRes.value)!; - } - const result: TGMKeyValue = {}; - if (Array.isArray(keysOrDefaults)) { - // 键名数组 - // Handle array of keys (e.g., ['foo', 'bar']) - for (let index = 0; index < keysOrDefaults.length; index++) { - const key = keysOrDefaults[index]; - if (key in this.scriptRes.value) { - // 对object的value进行一次转化 - let value = this.scriptRes.value[key]; - if (value && typeof value === "object") { - value = customClone(value)!; + "GM.setValues"(values: { [key: string]: any }): Promise { + if (!gtx.scriptRes) return new Promise(() => {}); + return new Promise((resolve) => { + if (!values || typeof values !== "object") { + throw new Error("GM.setValues: values must be an object"); } - result[key] = value; + _GM_setValues(resolve, values); + }); + }, + }), + + ...(hasGrant(scriptGrants, "GM.getValues", "GM_getValues") && { + GM_getValues(keysOrDefaults: TGMKeyValue | string[] | null | undefined) { + if (!gtx.scriptRes) return {}; + if (!keysOrDefaults) { + // Returns all values + return customClone(gtx.scriptRes.value)!; } - } - } else { - // 对象 键: 默认值 - // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) - for (const key of Object.keys(keysOrDefaults)) { - const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(this, key, defaultValue); - } - } - return result; - } - - // Asynchronous wrapper for GM.getValues - @GMContext.API({ depend: ["GM_getValues"] }) - public "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { - if (!this.scriptRes) return new Promise(() => {}); - return new Promise((resolve) => { - const ret = this.GM_getValues(keysOrDefaults); - resolve(ret); - }); - } - - @GMContext.API() - public "GM.setValues"(values: { [key: string]: any }): Promise { - if (!this.scriptRes) return new Promise(() => {}); - return new Promise((resolve) => { - if (!values || typeof values !== "object") { - throw new Error("GM.setValues: values must be an object"); - } - _GM_setValues(this, resolve, values); - }); - } - - @GMContext.API() - public GM_deleteValues(keys: string[]) { - if (!this.scriptRes) return; - if (!Array.isArray(keys)) { - console.warn("GM_deleteValues: keys must be string[]"); - return; - } - const req = {} as Record; - for (const key of keys) { - req[key] = undefined; - } - _GM_setValues(this, null, req); - } + const result: TGMKeyValue = {}; + if (Array.isArray(keysOrDefaults)) { + // 键名数组 + // Handle array of keys (e.g., ['foo', 'bar']) + for (let index = 0; index < keysOrDefaults.length; index++) { + const key = keysOrDefaults[index]; + if (key in gtx.scriptRes.value) { + // 对object的value进行一次转化 + let value = gtx.scriptRes.value[key]; + if (value && typeof value === "object") { + value = customClone(value)!; + } + result[key] = value; + } + } + } else { + // 对象 键: 默认值 + // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) + for (const key of Object.keys(keysOrDefaults)) { + const defaultValue = keysOrDefaults[key]; + result[key] = _GM_getValue(key, defaultValue); + } + } + return result; + }, - // Asynchronous wrapper for GM.deleteValues - @GMContext.API() - public "GM.deleteValues"(keys: string[]): Promise { - if (!this.scriptRes) return new Promise(() => {}); - return new Promise((resolve) => { - if (!Array.isArray(keys)) { - throw new Error("GM.deleteValues: keys must be string[]"); - } else { + // Asynchronous wrapper for GM.getValues + "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { + if (!gtx.scriptRes) return new Promise(() => {}); + return new Promise((resolve) => { + const ret = GM_getValues!(keysOrDefaults); + resolve(ret); + }); + }, + }), + + ...(hasGrant(scriptGrants, "GM.deleteValues", "GM_deleteValues") && { + GM_deleteValues(keys: string[]) { + if (!gtx.scriptRes) return; + if (!Array.isArray(keys)) { + console.warn("GM_deleteValues: keys must be string[]"); + return; + } const req = {} as Record; for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, resolve, req); - } - }); - } - - @GMContext.API() - public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number { - if (!this.valueChangeListener) return 0; - return this.valueChangeListener.add(name, listener); - } - - @GMContext.API({ depend: ["GM_addValueChangeListener"] }) - public "GM.addValueChangeListener"(name: string, listener: GMTypes.ValueChangeListener): Promise { - return new Promise((resolve) => { - const ret = this.GM_addValueChangeListener(name, listener); - resolve(ret); - }); - } - - @GMContext.API() - public GM_removeValueChangeListener(listenerId: number): void { - if (!this.valueChangeListener) return; - this.valueChangeListener.remove(listenerId); - } - - @GMContext.API({ depend: ["GM_removeValueChangeListener"] }) - public "GM.removeValueChangeListener"(listenerId: number): Promise { - return new Promise((resolve) => { - this.GM_removeValueChangeListener(listenerId); - resolve(); - }); - } - - @GMContext.API() - public GM_log(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): void { - if (this.isInvalidContext()) return; - if (typeof message !== "string") { - message = Native.jsonStringify(message); - } - this.sendMessage("GM_log", [message, level, labels]); - } + _GM_setValues(null, req); + }, - @GMContext.API({ depend: ["GM_log"] }) - public "GM.log"( - message: string, - level: GMTypes.LoggerLevel = "info", - ...labels: GMTypes.LoggerLabel[] - ): Promise { - return new Promise((resolve) => { - this.GM_log(message, level, ...labels); - resolve(); - }); - } + // Asynchronous wrapper for GM.deleteValues + "GM.deleteValues"(keys: string[]): Promise { + if (!gtx.scriptRes) return new Promise(() => {}); + return new Promise((resolve) => { + if (!Array.isArray(keys)) { + throw new Error("GM.deleteValues: keys must be string[]"); + } else { + const req = {} as Record; + for (const key of keys) { + req[key] = undefined; + } + _GM_setValues(resolve, req); + } + }); + }, + }), - @GMContext.API() - public CAT_createBlobUrl(blob: Blob): Promise { - return Promise.resolve(toBlobURL(this, blob)); - } + ...(hasGrant(scriptGrants, "GM_addValueChangeListener", "GM.addValueChangeListener") && { + GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number { + return gtx.valueChangeListener ? gtx.valueChangeListener.add(name, listener) : 0; + }, - // 辅助GM_xml获取blob数据 - @GMContext.API() - public CAT_fetchBlob(url: string): Promise { - return this.sendMessage("CAT_fetchBlob", [url]); - } + "GM.addValueChangeListener"(name: string, listener: GMTypes.ValueChangeListener): Promise { + return new Promise((resolve) => { + const ret = gtx.valueChangeListener ? gtx.valueChangeListener.add(name, listener) : 0; + resolve(ret); + }); + }, + }), - @GMContext.API() - public async CAT_fetchDocument(url: string): Promise { - return urlToDocumentInContentPage(this, url, isContent); - } + ...(hasGrant(scriptGrants, "GM_removeValueChangeListener", "GM.removeValueChangeListener") && { + GM_removeValueChangeListener(listenerId: number): void { + gtx.valueChangeListener?.remove(listenerId); + }, - static _GM_cookie( - a: IGM_Base, - action: string, - details: GMTypes.CookieDetails, - done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void - ) { - // 如果url和域名都没有,自动填充当前url - if (!details.url && !details.domain) { - details.url = window.location.href; - } - // 如果是set、delete操作,自动填充当前url - if (action === "set" || action === "delete") { - if (!details.url) { - details.url = window.location.href; - } - } - a.sendMessage("GM_cookie", [action, details]) - .then((resp: any) => { - done && done(resp, undefined); - }) - .catch((err) => { - done && done(undefined, err); - }); - } + "GM.removeValueChangeListener"(listenerId: number): Promise { + return new Promise((resolve) => { + gtx.valueChangeListener?.remove(listenerId); + resolve(); + }); + }, + }), - @GMContext.API() - public "GM.cookie"(action: string, details: GMTypes.CookieDetails) { - return new Promise((resolve, reject) => { - _GM_cookie(this, action, details, (cookie, error) => { - error ? reject(error) : resolve(cookie); - }); - }); - } + ...(hasGrant(scriptGrants, "GM_log", "GM.log") && { + GM_log(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): void { + if (gtx.isInvalidContext()) return; + if (typeof message !== "string") { + message = Native.jsonStringify(message); + } + gtx.sendMessage("GM_log", [message, level, labels]); + }, - @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.set"(details: GMTypes.CookieDetails) { - return new Promise((resolve, reject) => { - _GM_cookie(this, "set", details, (cookie, error) => { - error ? reject(error) : resolve(cookie); - }); - }); - } + "GM.log"(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): Promise { + return new Promise((resolve) => { + GM_log!(message, level, ...labels); + resolve(); + }); + }, + }), - @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.list"(details: GMTypes.CookieDetails) { - return new Promise((resolve, reject) => { - _GM_cookie(this, "list", details, (cookie, error) => { - error ? reject(error) : resolve(cookie); - }); - }); - } + ...(hasGrant(scriptGrants, "CAT_createBlobUrl") && { + CAT_createBlobUrl(blob: Blob): Promise { + return Promise.resolve(toBlobURL(gtx, blob)); + }, + }), - @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.delete"(details: GMTypes.CookieDetails) { - return new Promise((resolve, reject) => { - _GM_cookie(this, "delete", details, (cookie, error) => { - error ? reject(error) : resolve(cookie); - }); - }); - } + ...(hasGrant(scriptGrants, "CAT_fetchBlob") && { + // 辅助GM_xml获取blob数据 + CAT_fetchBlob(url: string): Promise { + return gtx.sendMessage("CAT_fetchBlob", [url]); + }, + }), - @GMContext.API({ follow: "GM_cookie" }) - public "GM_cookie.set"( - details: GMTypes.CookieDetails, - done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void - ) { - _GM_cookie(this, "set", details, done); - } + ...(hasGrant(scriptGrants, "CAT_fetchDocument") && { + async CAT_fetchDocument(url: string): Promise { + return urlToDocumentInContentPage(gtx, url, isContent); + }, + }), + + ...(hasGrant(scriptGrants, "GM.cookie", "GM_cookie") && { + GM_cookie( + action: string, + details: GMTypes.CookieDetails, + done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void + ) { + // 如果url和域名都没有,自动填充当前url + if (!details.url && !details.domain) { + details.url = window.location.href; + } + // 如果是set、delete操作,自动填充当前url + if (action === "set" || action === "delete") { + if (!details.url) { + details.url = window.location.href; + } + } + gtx + .sendMessage("GM_cookie", [action, details]) + .then((resp: any) => { + done && done(resp, undefined); + }) + .catch((err) => { + done && done(undefined, err); + }); + }, - @GMContext.API({ follow: "GM_cookie" }) - public "GM_cookie.list"( - details: GMTypes.CookieDetails, - done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void - ) { - _GM_cookie(this, "list", details, done); - } + "GM.cookie"(action: string, details: GMTypes.CookieDetails) { + return new Promise((resolve, reject) => { + GM_cookie!(action, details, (cookie, error) => { + error ? reject(error) : resolve(cookie); + }); + }); + }, - @GMContext.API({ follow: "GM_cookie" }) - public "GM_cookie.delete"( - details: GMTypes.CookieDetails, - done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void - ) { - _GM_cookie(this, "delete", details, done); - } + "GM.cookie.set"(details: GMTypes.CookieDetails) { + return new Promise((resolve, reject) => { + GM_cookie!("set", details, (cookie, error) => { + error ? reject(error) : resolve(cookie); + }); + }); + }, - @GMContext.API() - public GM_cookie( - action: string, - details: GMTypes.CookieDetails, - done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void - ) { - _GM_cookie(this, action, details, done); - } + "GM.cookie.list"(details: GMTypes.CookieDetails) { + return new Promise((resolve, reject) => { + GM_cookie!("list", details, (cookie, error) => { + error ? reject(error) : resolve(cookie); + }); + }); + }, - // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 - // 唯一键格式:{contentEnvKey}.t{注册ID},由 execEnvInit() 建立/维护。 - menuKeyRegistered: Set | undefined; + "GM.cookie.delete"(details: GMTypes.CookieDetails) { + return new Promise((resolve, reject) => { + GM_cookie!("delete", details, (cookie, error) => { + error ? reject(error) : resolve(cookie); + }); + }); + }, - // 自动产生的菜单 ID 累计器(仅在未提供 options.id 时使用)。 - // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 - menuIdCounter: number | undefined; + "GM_cookie.set"( + details: GMTypes.CookieDetails, + done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void + ) { + GM_cookie!("set", details, done); + }, - // 菜单注册累计器 - 用于稳定同一Tab不同frame之选项的单独项目不合并状态 - // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 - regMenuCounter: number | undefined; + "GM_cookie.list"( + details: GMTypes.CookieDetails, + done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void + ) { + GM_cookie!("list", details, done); + }, - // 内容脚本执行环境识别符,用于区分 mainframe / subframe 等环境并作为 menu key 的命名空间。 - // 由 execEnvInit() 以 randomMessageFlag() 生成,避免跨 frame 的 ID 碰撞。 - // (同一环境跨脚本也不一样) - contentEnvKey: string | undefined; + "GM_cookie.delete"( + details: GMTypes.CookieDetails, + done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void + ) { + GM_cookie!("delete", details, done); + }, + }), + + _GM_registerMenuCommand( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: ScriptMenuItemOption | string + ): TScriptMenuItemID { + if (!gtx.EE) return -1; + execEnvInit(gtx); + gtx.regMenuCounter! += 1; + // 兼容 GM_registerMenuCommand(name, options_or_accessKey) + if (!options_or_accessKey && typeof listener === "object") { + options_or_accessKey = listener; + listener = undefined; + } + // 浅拷贝避免修改/共用参数 + const options: SWScriptMenuItemOption = ( + typeof options_or_accessKey === "string" + ? { accessKey: options_or_accessKey } + : options_or_accessKey + ? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作) + : {} + ) as ScriptMenuItemOption; + const isSeparator = !listener && !name; + let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined; + if (isIndividual === undefined && isSeparator) { + isIndividual = true; + } + options.mIndividualKey = isIndividual ? gtx.regMenuCounter : 0; + if (options.autoClose === undefined) { + options.autoClose = true; + } + if (options.nested === undefined) { + options.nested = true; + } + if (isSeparator) { + // GM_registerMenuCommand("") 时自动设为分隔线 + options.mSeparator = true; + name = ""; + listener = undefined; + } else { + options.mSeparator = false; + } + let providedId: string | number | undefined = + typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; + if (providedId === undefined) providedId = gtx.menuIdCounter! += 1; // 如无指定,使用累计器id + const ret = providedId! as TScriptMenuItemID; + providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 + providedId = `${gtx.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID} + // 检查之前有否注册 + if (menuKey && gtx.menuKeyRegistered!.has(menuKey)) { + // 有注册过,先移除 listeners + gtx.EE.removeAllListeners("menuClick:" + menuKey); + } else { + // 没注册过,先记录一下 + gtx.menuKeyRegistered!.add(menuKey); + } + if (listener) { + // GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错 + gtx.EE.addListener("menuClick:" + menuKey, listener); + } + // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) + gtx.sendMessage("GM_registerMenuCommand", [menuKey, name, options] as GMRegisterMenuCommandParam); + return ret; + }, + + ...(hasGrant(scriptGrants, "GM_registerMenuCommand", "GM.registerMenuCommand") && { + GM_registerMenuCommand( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: ScriptMenuItemOption | string + ): TScriptMenuItemID { + return _GM_registerMenuCommand(name, listener, options_or_accessKey); + }, - @GMContext.API() - public GM_registerMenuCommand( - name: string, - listener?: (inputValue?: any) => void, - options_or_accessKey?: ScriptMenuItemOption | string - ): TScriptMenuItemID { - if (!this.EE) return -1; - execEnvInit(this); - this.regMenuCounter! += 1; - // 兼容 GM_registerMenuCommand(name, options_or_accessKey) - if (!options_or_accessKey && typeof listener === "object") { - options_or_accessKey = listener; - listener = undefined; - } - // 浅拷贝避免修改/共用参数 - const options: SWScriptMenuItemOption = ( - typeof options_or_accessKey === "string" - ? { accessKey: options_or_accessKey } - : options_or_accessKey - ? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作) - : {} - ) as ScriptMenuItemOption; - const isSeparator = !listener && !name; - let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined; - if (isIndividual === undefined && isSeparator) { - isIndividual = true; - } - options.mIndividualKey = isIndividual ? this.regMenuCounter : 0; - if (options.autoClose === undefined) { - options.autoClose = true; - } - if (options.nested === undefined) { - options.nested = true; - } - if (isSeparator) { - // GM_registerMenuCommand("") 时自动设为分隔线 - options.mSeparator = true; - name = ""; - listener = undefined; - } else { - options.mSeparator = false; - } - let providedId: string | number | undefined = - typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; - if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id - const ret = providedId! as TScriptMenuItemID; - providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 - providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 - const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID} - // 检查之前有否注册 - if (menuKey && this.menuKeyRegistered!.has(menuKey)) { - // 有注册过,先移除 listeners - this.EE.removeAllListeners("menuClick:" + menuKey); - } else { - // 没注册过,先记录一下 - this.menuKeyRegistered!.add(menuKey); - } - if (listener) { - // GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错 - this.EE.addListener("menuClick:" + menuKey, listener); - } - // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) - this.sendMessage("GM_registerMenuCommand", [menuKey, name, options] as GMRegisterMenuCommandParam); - return ret; - } + "GM.registerMenuCommand"( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: ScriptMenuItemOption | string + ): Promise { + return new Promise((resolve) => { + const ret = _GM_registerMenuCommand(name, listener, options_or_accessKey); + resolve(ret); + }); + }, + }), + + ...(hasGrant(scriptGrants, "CAT_registerMenuInput") && { + CAT_registerMenuInput( + name: string, + listener?: (inputValue?: any) => void, + options_or_accessKey?: ScriptMenuItemOption | string + ): TScriptMenuItemID { + return _GM_registerMenuCommand(name, listener, options_or_accessKey); + }, + }), - @GMContext.API({ depend: ["GM_registerMenuCommand"] }) - public "GM.registerMenuCommand"( - name: string, - listener?: (inputValue?: any) => void, - options_or_accessKey?: ScriptMenuItemOption | string - ): Promise { - return new Promise((resolve) => { - const ret = this.GM_registerMenuCommand(name, listener, options_or_accessKey); - resolve(ret); - }); - } + _GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { + if (!gtx.EE) return; + if (!gtx.contentEnvKey) { + return; + } + let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释 + menuKey = `${gtx.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + gtx.menuKeyRegistered!.delete(menuKey); + gtx.EE.removeAllListeners("menuClick:" + menuKey); + // 发送至 service worker 处理(唯一键) + gtx.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); + }, + + ...(hasGrant(scriptGrants, "GM_unregisterMenuCommand", "GM.unregisterMenuCommand") && { + GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { + return _GM_unregisterMenuCommand(menuId); + }, - @GMContext.API({ depend: ["GM_registerMenuCommand"] }) - public CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { - return this.GM_registerMenuCommand(...args); - } + "GM.unregisterMenuCommand"(menuId: TScriptMenuItemID): Promise { + return new Promise((resolve) => { + _GM_unregisterMenuCommand(menuId); + resolve(); + }); + }, + }), - @GMContext.API() - public GM_addStyle(css: string): Element | undefined { - if (!this.message || !this.scriptRes) return; - if (typeof css !== "string") throw new Error("The parameter 'css' of GM_addStyle shall be a string."); - // 与content页的消息通讯实际是同步,此方法不需要经过background - // 这里直接使用同步的方式去处理, 不要有promise - const resp = (this.message).syncSendMessage({ - action: `${this.prefix}/runtime/gmApi`, - data: { - uuid: this.scriptRes.uuid, - api: "GM_addElement", - params: [ - null, - "style", - { - textContent: css, + ...(hasGrant(scriptGrants, "CAT_unregisterMenuInput") && { + CAT_unregisterMenuInput(menuId: TScriptMenuItemID): void { + _GM_unregisterMenuCommand(menuId); + }, + }), + + ...(hasGrant(scriptGrants, "GM_addStyle", "GM.addStyle") && { + GM_addStyle(css: string): Element | undefined { + if (!gtx.message || !gtx.scriptRes) return; + if (typeof css !== "string") throw new Error("The parameter 'css' of GM_addStyle shall be a string."); + // 与content页的消息通讯实际是同步,此方法不需要经过background + // 这里直接使用同步的方式去处理, 不要有promise + const resp = (gtx.message).syncSendMessage({ + action: `${gtx.prefix}/runtime/gmApi`, + data: { + uuid: gtx.scriptRes.uuid, + api: "GM_addElement", + params: [ + null, + "style", + { + textContent: css, + }, + isContent, + ], }, - isContent, - ], + }); + if (resp.code) { + throw new Error(resp.message); + } + return (gtx.message).getAndDelRelatedTarget(resp.data) as Element; }, - }); - if (resp.code) { - throw new Error(resp.message); - } - return (this.message).getAndDelRelatedTarget(resp.data) as Element; - } - - @GMContext.API({ depend: ["GM_addStyle"] }) - public "GM.addStyle"(css: string): Promise { - return new Promise((resolve) => { - const ret = this.GM_addStyle(css); - resolve(ret); - }); - } - @GMContext.API() - public GM_addElement( - parentNode: Node | string, - tagName: string | Record, - attrs: Record = {} - ): Element | undefined { - if (!this.message || !this.scriptRes) return; - // 与content页的消息通讯实际是同步,此方法不需要经过background - // 这里直接使用同步的方式去处理, 不要有promise - let parentNodeId: number | null; - if (typeof parentNode !== "string") { - const id = (this.message).sendRelatedTarget(parentNode); - parentNodeId = id; - } else { - parentNodeId = null; - attrs = (tagName || {}) as Record; - tagName = parentNode as string; - } - if (typeof tagName !== "string") throw new Error("The parameter 'tagName' of GM_addElement shall be a string."); - if (typeof attrs !== "object") throw new Error("The parameter 'attrs' of GM_addElement shall be an object."); - const resp = (this.message).syncSendMessage({ - action: `${this.prefix}/runtime/gmApi`, - data: { - uuid: this.scriptRes.uuid, - api: "GM_addElement", - params: [parentNodeId, tagName, attrs, isContent], + "GM.addStyle"(css: string): Promise { + return new Promise((resolve) => { + const ret = GM_addStyle!(css); + resolve(ret); + }); }, - }); - if (resp.code) { - throw new Error(resp.message); - } - return (this.message).getAndDelRelatedTarget(resp.data) as Element; - } - - @GMContext.API({ depend: ["GM_addElement"] }) - public "GM.addElement"( - parentNode: Node | string, - tagName: string | Record, - attrs: Record = {} - ): Promise { - return new Promise((resolve) => { - const ret = this.GM_addElement(parentNode, tagName, attrs); - resolve(ret); - }); - } - - @GMContext.API() - public GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { - if (!this.EE) return; - if (!this.contentEnvKey) { - return; - } - let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释 - menuKey = `${this.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 - this.menuKeyRegistered!.delete(menuKey); - this.EE.removeAllListeners("menuClick:" + menuKey); - // 发送至 service worker 处理(唯一键) - this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); - } - - @GMContext.API({ depend: ["GM_unregisterMenuCommand"] }) - public "GM.unregisterMenuCommand"(menuId: TScriptMenuItemID): Promise { - return new Promise((resolve) => { - this.GM_unregisterMenuCommand(menuId); - resolve(); - }); - } - - @GMContext.API({ - depend: ["GM_unregisterMenuCommand"], - }) - public CAT_unregisterMenuInput(...args: Parameters): void { - this.GM_unregisterMenuCommand(...args); - } - - @GMContext.API() - public CAT_userConfig() { - return this.sendMessage("CAT_userConfig", []); - } - - @GMContext.API({ - depend: ["CAT_fetchBlob"], - }) - public async CAT_fileStorage(action: "list" | "download" | "upload" | "delete" | "config", details: any) { - if (action === "config") { - this.sendMessage("CAT_fileStorage", ["config"]); - return; - } - const sendDetails: CATType.CATFileStorageDetails = { - baseDir: details.baseDir || "", - path: details.path || "", - filename: details.filename, - file: details.file, - }; - if (action === "upload") { - const url = await toBlobURL(this, details.data); - sendDetails.data = url; - } - this.sendMessage("CAT_fileStorage", [action, sendDetails]).then(async (resp: { action: string; data: any }) => { - switch (resp.action) { - case "onload": { - if (action === "download") { - // 读取blob - const blob = await this.CAT_fetchBlob(resp.data); - details.onload && details.onload(blob); - } else { - details.onload && details.onload(resp.data); - } - break; + }), + + ...(hasGrant(scriptGrants, "GM_addElement", "GM.addElement") && { + GM_addElement( + parentNode: Node | string, + tagName: string | Record, + attrs: Record = {} + ): Element | undefined { + if (!gtx.message || !gtx.scriptRes) return; + // 与content页的消息通讯实际是同步,此方法不需要经过background + // 这里直接使用同步的方式去处理, 不要有promise + let parentNodeId: number | null; + if (typeof parentNode !== "string") { + const id = (gtx.message).sendRelatedTarget(parentNode); + parentNodeId = id; + } else { + parentNodeId = null; + attrs = (tagName || {}) as Record; + tagName = parentNode as string; } - case "error": { - if (typeof resp.data.code === "undefined") { - details.onerror && details.onerror({ code: -1, message: resp.data.message }); - return; - } - details.onerror && details.onerror(resp.data); + if (typeof tagName !== "string") throw new Error("The parameter 'tagName' of GM_addElement shall be a string."); + if (typeof attrs !== "object") throw new Error("The parameter 'attrs' of GM_addElement shall be an object."); + const resp = (gtx.message).syncSendMessage({ + action: `${gtx.prefix}/runtime/gmApi`, + data: { + uuid: gtx.scriptRes.uuid, + api: "GM_addElement", + params: [parentNodeId, tagName, attrs, isContent], + }, + }); + if (resp.code) { + throw new Error(resp.message); } - } - }); - } + return (gtx.message).getAndDelRelatedTarget(resp.data) as Element; + }, - // 用于脚本跨域请求,需要@connect domain指定允许的域名 - @GMContext.API() - public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - const { abort } = GM_xmlhttpRequest(this, details, false); - return { abort }; - } + "GM.addElement"( + parentNode: Node | string, + tagName: string | Record, + attrs: Record = {} + ): Promise { + return new Promise((resolve) => { + const ret = GM_addElement!(parentNode, tagName, attrs); + resolve(ret); + }); + }, + }), - @GMContext.API() - 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; - } + ...(hasGrant(scriptGrants, "CAT_userConfig") && { + CAT_userConfig() { + return gtx.sendMessage("CAT_userConfig", []); + }, + }), - /** - * - * SC的 downloadMode 设置在API呼叫,TM 的 downloadMode 设置在扩展设定 - * native, disabled, browser - * native: 后台xhr下载 -> 后台chrome.download API,disabled: 禁止下载,browser: 后台chrome.download API - * - */ - 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 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; - 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 - } + ...(hasGrant(scriptGrants, "CAT_fileStorage") && { + async CAT_fileStorage(action: "list" | "download" | "upload" | "delete" | "config", details: any) { + if (action === "config") { + gtx.sendMessage("CAT_fileStorage", ["config"]); + return; } - 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?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - break; - case "onprogress": - details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("Unknown ERROR")); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); + const sendDetails: CATType.CATFileStorageDetails = { + baseDir: details.baseDir || "", + path: details.path || "", + filename: details.filename, + file: details.file, + }; + if (action === "upload") { + const url = await toBlobURL(gtx, details.data); + sendDetails.data = url; + } + gtx.sendMessage("CAT_fileStorage", [action, sendDetails]).then(async (resp: { action: string; data: any }) => { + switch (resp.action) { + case "onload": { + if (action === "download") { + // 读取blob + const blob = await gtx.sendMessage("CAT_fetchBlob", [resp.data]); + details.onload && details.onload(blob); + } else { + details.onload && details.onload(resp.data); + } break; - } - }); - } else { - // native - const xhrParams = { - url: url, - fetch: true, // 跟随TM使用 fetch; 使用 fetch 避免 1) 大量数据存放offscreen xhr 2) vivaldi offscreen client block - responseType: "blob", - onloadend: async (res) => { - if (aborted) return; - if (res.response instanceof Blob) { - const url = URL.createObjectURL(res.response); // 生命周期跟随当前 content/page 而非 offscreen - const con = await a.connect("GM_download", [ - { - method: details.method, - downloadMode: "browser", - url: url as string, - name: details.name, - headers: details.headers, - saveAs: details.saveAs, - timeout: details.timeout, - cookie: details.cookie, - anonymous: details.anonymous, - } as GMTypes.DownloadDetails, - ]); - if (aborted) return; - connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - setTimeout(() => { - // 释放不需要的 URL - URL.revokeObjectURL(url); - }, 1); - break; - case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("Unknown ERROR")); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - break; - } - }); } - }, - onload: () => { - // details.onload?.(makeCallbackParam({})) - }, - onprogress: (e) => { - details.onprogress?.(makeCallbackParam({ ...e, mode: "native" })); - }, - ontimeout: () => { - details.ontimeout?.(makeCallbackParam({})); - }, - onerror: () => { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - }, - } as GMTypes.XHRDetails; - if (typeof details.headers === "object") { - xhrParams.headers = details.headers; - } - // -- 其他参数 -- - if (typeof details.method === "string") { - xhrParams.method = details.method || "GET"; - } - if (typeof details.timeout === "number") { - xhrParams.timeout = details.timeout; - } - if (typeof details.cookie === "string") { - xhrParams.cookie = details.cookie; - } - if (typeof details.anonymous === "boolean") { - xhrParams.anonymous = details.anonymous; - } - if (typeof details.user === "string" && details.user) { - xhrParams.user = details.user; - xhrParams.password = details.password || ""; - } - // -- 其他参数 -- - const { retPromise, abort } = GM_xmlhttpRequest(a, xhrParams, true, true); - retPromise?.catch(() => { - if (aborted) return; - retPromiseReject?.(new Error("Native Download ERROR")); + case "error": { + if (typeof resp.data.code === "undefined") { + details.onerror && details.onerror({ code: -1, message: resp.data.message }); + return; + } + details.onerror && details.onerror(resp.data); + } + } }); - 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; - } + ...(hasGrant(scriptGrants, "GM_xmlhttpRequest", "GM.xmlHttpRequest", "GM_xmlHttpRequest", "GM.xmlhttpRequest") && { + // 用于脚本跨域请求,需要@connect domain指定允许的域名 + GM_xmlhttpRequest(details: GMTypes.XHRDetails) { + const { abort } = GM_xmlhttpRequest(gtx, details, false); + return { abort }; + }, - static _GM_notification( - gmApi: GMApi, - detail: GMTypes.NotificationDetails | string, - ondone?: GMTypes.NotificationOnDone | string, - image?: string, - onclick?: GMTypes.NotificationOnClick - ): Promise { - if (gmApi.isInvalidContext()) return Promise.resolve(); - const notificationTagMap: Map = gmApi.notificationTagMap || (gmApi.notificationTagMap = new Map()); - gmApi.eventId += 1; - let data: GMTypes.NotificationDetails; - if (typeof detail === "string") { - data = {}; - data.text = detail; - switch (arguments.length) { - case 4: - data.onclick = onclick; - // eslint-disable-next-line no-fallthrough - case 3: - data.image = image; - // eslint-disable-next-line no-fallthrough - case 2: - data.title = ondone; - // eslint-disable-next-line no-fallthrough - default: - break; - } - } else { - data = Object.assign({}, detail); - data.ondone = data.ondone || ondone; - } - let click: GMTypes.NotificationOnClick; - let done: GMTypes.NotificationOnDone; - let create: GMTypes.NotificationOnClick; - if (data.onclick) { - click = data.onclick; - delete data.onclick; - } - if (data.ondone) { - done = data.ondone; - delete data.ondone; - } - if (data.oncreate) { - create = data.oncreate; - delete data.oncreate; - } - let notificationId: string | undefined = undefined; - if (typeof data.tag === "string") { - notificationId = notificationTagMap.get(data.tag); - } - gmApi.sendMessage("GM_notification", [data, notificationId]).then((id) => { - if (!gmApi.EE) return; - if (create) { - create.apply({ id }, [id]); - } - if (typeof data.tag === "string") { - notificationTagMap.set(data.tag, id); + "GM.xmlHttpRequest"(details: GMTypes.XHRDetails): Promise & GMRequestHandle { + const { retPromise, abort } = GM_xmlhttpRequest(gtx, details, true); + const ret = retPromise as Promise & GMRequestHandle; + ret.abort = abort; + return ret; + }, + }), + + /** + * + * SC的 downloadMode 设置在API呼叫,TM 的 downloadMode 设置在扩展设定 + * native, disabled, browser + * native: 后台xhr下载 -> 后台chrome.download API,disabled: 禁止下载,browser: 后台chrome.download API + * + */ + _GM_download(details: GMTypes.DownloadDetails, requirePromise: boolean) { + if (gtx.isInvalidContext()) { + return { + retPromise: requirePromise ? Promise.reject("GM_download: Invalid Context") : null, + abort: () => {}, + }; } - let isPreventDefault = false; - gmApi.EE.addListener("GM_notification:" + id, (resp: NotificationMessageOption) => { - if (!gmApi.EE) return; - /** - * 清除保存的通知的tag - */ - const clearNotificationIdMap = () => { - if (typeof data.tag === "string") { - notificationTagMap.delete(data.tag); + 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; + 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 + } } - }; - switch (resp.event) { - case "click": - case "buttonClick": { - const clickEvent: GMTypes.NotificationOnClickEvent = { - event: resp.event, - id: id, - isButtonClick: resp.event === "buttonClick", - buttonClickIndex: resp.params.index, - byUser: resp.params.byUser, - preventDefault: function () { - isPreventDefault = true; - }, - highlight: data.highlight, - image: data.image, - silent: data.silent, - tag: data.tag, - text: data.tag, - timeout: data.timeout, - title: data.title, - url: data.url, - }; - click && click.apply({ id }, [clickEvent]); - done && done.apply({ id }, []); - - if (!isPreventDefault) { - if (typeof data.url === "string") { - window.open(data.url, "_blank"); - LoggerCore.logger().info("GM_notification open url: " + data.url, { + const con = await gtx.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?.(makeCallbackParam({ ...data.data })); + retPromiseResolve?.(data.data); + break; + case "onprogress": + details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "ontimeout": + details.ontimeout?.(makeCallbackParam({})); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "onerror": + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + retPromiseReject?.(new Error("Unknown ERROR")); + break; + default: + LoggerCore.logger().warn("GM_download resp is error", { data, }); - } + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + break; } - break; + }); + } else { + // native + const xhrParams = { + url: url, + fetch: true, // 跟随TM使用 fetch; 使用 fetch 避免 1) 大量数据存放offscreen xhr 2) vivaldi offscreen client block + responseType: "blob", + onloadend: async (res) => { + if (aborted) return; + if (res.response instanceof Blob) { + const url = URL.createObjectURL(res.response); // 生命周期跟随当前 content/page 而非 offscreen + const con = await gtx.connect("GM_download", [ + { + method: details.method, + downloadMode: "browser", + url: url as string, + name: details.name, + headers: details.headers, + saveAs: details.saveAs, + timeout: details.timeout, + cookie: details.cookie, + anonymous: details.anonymous, + } as GMTypes.DownloadDetails, + ]); + if (aborted) return; + connect = con; + connect.onMessage((data) => { + switch (data.action) { + case "onload": + details.onload?.(makeCallbackParam({ ...data.data })); + retPromiseResolve?.(data.data); + setTimeout(() => { + // 释放不需要的 URL + URL.revokeObjectURL(url); + }, 1); + break; + case "ontimeout": + details.ontimeout?.(makeCallbackParam({})); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "onerror": + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + retPromiseReject?.(new Error("Unknown ERROR")); + break; + default: + LoggerCore.logger().warn("GM_download resp is error", { + data, + }); + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + break; + } + }); + } + }, + onload: () => { + // details.onload?.(makeCallbackParam({})) + }, + onprogress: (e) => { + details.onprogress?.(makeCallbackParam({ ...e, mode: "native" })); + }, + ontimeout: () => { + details.ontimeout?.(makeCallbackParam({})); + }, + onerror: () => { + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + }, + } as GMTypes.XHRDetails; + if (typeof details.headers === "object") { + xhrParams.headers = details.headers; } - case "close": { - done && done.apply({ id }, [resp.params.byUser]); - clearNotificationIdMap(); - gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); - break; + // -- 其他参数 -- + if (typeof details.method === "string") { + xhrParams.method = details.method || "GET"; } - default: - LoggerCore.logger().warn("GM_notification resp is error", { - resp, - }); - break; + if (typeof details.timeout === "number") { + xhrParams.timeout = details.timeout; + } + if (typeof details.cookie === "string") { + xhrParams.cookie = details.cookie; + } + if (typeof details.anonymous === "boolean") { + xhrParams.anonymous = details.anonymous; + } + if (typeof details.user === "string" && details.user) { + xhrParams.user = details.user; + xhrParams.password = details.password || ""; + } + // -- 其他参数 -- + const { retPromise, abort } = GM_xmlhttpRequest(gtx, xhrParams, true, true); + retPromise?.catch(() => { + if (aborted) return; + retPromiseReject?.(new Error("Native Download ERROR")); + }); + nativeAbort = abort; } - }); - }); - return Promise.resolve(); - } - - @GMContext.API() - public async "GM.notification"( - detail: GMTypes.NotificationDetails | string, - ondone?: GMTypes.NotificationOnDone | string, - image?: string, - onclick?: GMTypes.NotificationOnClick - ): Promise { - return _GM_notification(this, detail, ondone, image, onclick); - } - - @GMContext.API() - public GM_notification( - detail: GMTypes.NotificationDetails | string, - ondone?: GMTypes.NotificationOnDone | string, - image?: string, - onclick?: GMTypes.NotificationOnClick - ): void { - _GM_notification(this, detail, ondone, image, onclick); - } - - // ScriptCat 额外API - @GMContext.API({ alias: "GM.closeNotification" }) - public GM_closeNotification(id: string): void { - this.sendMessage("GM_closeNotification", [id]); - } + }; + handle().catch(console.error); - // ScriptCat 额外API - @GMContext.API({ alias: "GM.updateNotification" }) - public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void { - this.sendMessage("GM_updateNotification", [id, details]); - } + return { + retPromise, + abort: () => { + aborted = true; + connect?.disconnect(); + nativeAbort?.(); + }, + }; + }, + + ...(hasGrant(scriptGrants, "GM_download", "GM.download") && { + // 用于脚本跨域请求,需要@connect domain指定允许的域名 + GM_download(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; + const { abort } = _GM_download(details as GMTypes.DownloadDetails, false); + return { abort }; + }, - @GMContext.API({ depend: ["GM_closeInTab"] }) - public GM_openInTab(url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { - if (this.isInvalidContext()) return undefined; - let option = {} as GMTypes.OpenTabOptions; - if (typeof param === "boolean") { - option.active = !param; // Greasemonkey 3.x loadInBackground - } else if (param) { - option = { ...param } as GMTypes.OpenTabOptions; - } - if (typeof option.active !== "boolean" && typeof option.loadInBackground === "boolean") { - // TM 同时兼容 active 和 loadInBackground ( active 优先 ) - option.active = !option.loadInBackground; - } else if (option.active === undefined) { - option.active = true; // TM 预设 active: false;VM 预设 active: true;旧SC 预设 active: true;GM 依从 浏览器 - } - if (option.insert === undefined) { - option.insert = true; // TM 预设 insert: true;VM 预设 insert: true;旧SC 无此设计 (false) - } - if (option.setParent === undefined) { - option.setParent = true; // TM 预设 setParent: false; 旧SC 预设 setParent: true; - // SC 预设 setParent: true 以避免不可预计的问题 - } - let tabid: any; - - const ret: GMTypes.Tab = { - close: () => { - tabid && this.GM_closeInTab(tabid); - }, - closed: false, - // 占位 - onclose() {}, - }; - - this.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { - if (!this.EE) return; - if (id) { - tabid = id; - this.EE.addListener("GM_openInTab:" + id, (resp: any) => { - if (!this.EE) return; + "GM.download"(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; + const { retPromise, abort } = _GM_download(details as GMTypes.DownloadDetails, true); + const ret = retPromise as Promise & GMRequestHandle; + ret.abort = abort; + return ret; + }, + }), + + _GM_notification( + detail: GMTypes.NotificationDetails | string, + ondone?: GMTypes.NotificationOnDone | string, + image?: string, + onclick?: GMTypes.NotificationOnClick + ): Promise { + if (gtx.isInvalidContext()) return Promise.resolve(); + const notificationTagMap: Map = gtx.notificationTagMap || (gtx.notificationTagMap = new Map()); + gtx.eventId += 1; + let data: GMTypes.NotificationDetails; + if (typeof detail === "string") { + data = {}; + data.text = detail; + switch (arguments.length) { + case 4: + data.onclick = onclick; + // eslint-disable-next-line no-fallthrough + case 3: + data.image = image; + // eslint-disable-next-line no-fallthrough + case 2: + data.title = ondone; + // eslint-disable-next-line no-fallthrough + default: + break; + } + } else { + data = Object.assign({}, detail); + data.ondone = data.ondone || ondone; + } + let click: GMTypes.NotificationOnClick; + let done: GMTypes.NotificationOnDone; + let create: GMTypes.NotificationOnClick; + if (data.onclick) { + click = data.onclick; + delete data.onclick; + } + if (data.ondone) { + done = data.ondone; + delete data.ondone; + } + if (data.oncreate) { + create = data.oncreate; + delete data.oncreate; + } + let notificationId: string | undefined = undefined; + if (typeof data.tag === "string") { + notificationId = notificationTagMap.get(data.tag); + } + gtx.sendMessage("GM_notification", [data, notificationId]).then((id) => { + if (!gtx.EE) return; + if (create) { + create.apply({ id }, [id]); + } + if (typeof data.tag === "string") { + notificationTagMap.set(data.tag, id); + } + let isPreventDefault = false; + gtx.EE.addListener("GM_notification:" + id, (resp: NotificationMessageOption) => { + if (!gtx.EE) return; + /** + * 清除保存的通知的tag + */ + const clearNotificationIdMap = () => { + if (typeof data.tag === "string") { + notificationTagMap.delete(data.tag); + } + }; switch (resp.event) { - case "oncreate": - tabid = resp.tabId; + case "click": + case "buttonClick": { + const clickEvent: GMTypes.NotificationOnClickEvent = { + event: resp.event, + id: id, + isButtonClick: resp.event === "buttonClick", + buttonClickIndex: resp.params.index, + byUser: resp.params.byUser, + preventDefault: function () { + isPreventDefault = true; + }, + highlight: data.highlight, + image: data.image, + silent: data.silent, + tag: data.tag, + text: data.tag, + timeout: data.timeout, + title: data.title, + url: data.url, + }; + click && click.apply({ id }, [clickEvent]); + done && done.apply({ id }, []); + + if (!isPreventDefault) { + if (typeof data.url === "string") { + window.open(data.url, "_blank"); + LoggerCore.logger().info("GM_notification open url: " + data.url, { + data, + }); + } + } break; - case "onclose": - ret.onclose && ret.onclose(); - ret.closed = true; - this.EE.removeAllListeners("GM_openInTab:" + id); + } + case "close": { + done && done.apply({ id }, [resp.params.byUser]); + clearNotificationIdMap(); + gtx.EE.removeAllListeners("GM_notification:" + gtx.eventId); break; + } default: - LoggerCore.logger().warn("GM_openInTab resp is error", { + LoggerCore.logger().warn("GM_notification resp is error", { resp, }); break; } }); - } else { - ret.onclose && ret.onclose(); - ret.closed = true; - } - }); + }); + return Promise.resolve(); + }, + + ...(hasGrant(scriptGrants, "GM.notification", "GM_notification") && { + async "GM.notification"( + detail: GMTypes.NotificationDetails | string, + ondone?: GMTypes.NotificationOnDone | string, + image?: string, + onclick?: GMTypes.NotificationOnClick + ): Promise { + return _GM_notification(detail, ondone, image, onclick); + }, - return ret; - } + GM_notification( + detail: GMTypes.NotificationDetails | string, + ondone?: GMTypes.NotificationOnDone | string, + image?: string, + onclick?: GMTypes.NotificationOnClick + ): void { + _GM_notification(detail, ondone, image, onclick); + }, + }), - @GMContext.API({ depend: ["GM_openInTab", "GM_closeInTab"] }) - public "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { - return new Promise((resolve) => { - const ret = this.GM_openInTab(url, param); - resolve(ret); - }); - } + ...(hasGrant(scriptGrants, "GM_closeNotification", "GM.closeNotification") && { + // ScriptCat 额外API + GM_closeNotification(id: string): void { + if (gtx.isInvalidContext()) return; + gtx.sendMessage("GM_closeNotification", [id]); + }, + "GM.closeNotification"(id: string): void { + if (gtx.isInvalidContext()) return; + gtx.sendMessage("GM_closeNotification", [id]); + }, + }), - // ScriptCat 额外API - @GMContext.API({ alias: "GM.closeInTab" }) - public GM_closeInTab(tabid: string) { - if (this.isInvalidContext()) return; - return this.sendMessage("GM_closeInTab", [tabid]); - } + ...(hasGrant(scriptGrants, "GM_updateNotification", "GM.updateNotification") && { + // ScriptCat 额外API + GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void { + if (gtx.isInvalidContext()) return; + gtx.sendMessage("GM_updateNotification", [id, details]); + }, + "GM.updateNotification"(id: string, details: GMTypes.NotificationDetails): void { + if (gtx.isInvalidContext()) return; + gtx.sendMessage("GM_updateNotification", [id, details]); + }, + }), + + ...(hasGrant(scriptGrants, "GM_openInTab", "GM.openInTab") && { + GM_openInTab(url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { + if (gtx.isInvalidContext()) return undefined; + let option = {} as GMTypes.OpenTabOptions; + if (typeof param === "boolean") { + option.active = !param; // Greasemonkey 3.x loadInBackground + } else if (param) { + option = { ...param } as GMTypes.OpenTabOptions; + } + if (typeof option.active !== "boolean" && typeof option.loadInBackground === "boolean") { + // TM 同时兼容 active 和 loadInBackground ( active 优先 ) + option.active = !option.loadInBackground; + } else if (option.active === undefined) { + option.active = true; // TM 预设 active: false;VM 预设 active: true;旧SC 预设 active: true;GM 依从 浏览器 + } + if (option.insert === undefined) { + option.insert = true; // TM 预设 insert: true;VM 预设 insert: true;旧SC 无此设计 (false) + } + if (option.setParent === undefined) { + option.setParent = true; // TM 预设 setParent: false; 旧SC 预设 setParent: true; + // SC 预设 setParent: true 以避免不可预计的问题 + } + let tabid: any; - @GMContext.API() - public GM_getTab(callback: (tabData: object) => void) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTab", []).then((tabData) => { - callback(tabData ?? {}); - }); - } + const ret: GMTypes.Tab = { + close: () => { + tabid && !gtx.isInvalidContext() && gtx.sendMessage("GM_closeInTab", [tabid]); + }, + closed: false, + // 占位 + onclose() {}, + }; - @GMContext.API({ depend: ["GM_getTab"] }) - public "GM.getTab"(): Promise { - return new Promise((resolve) => { - this.GM_getTab((data) => { - resolve(data); - }); - }); - } + gtx.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { + if (!gtx.EE) return; + if (id) { + tabid = id; + gtx.EE.addListener("GM_openInTab:" + id, (resp: any) => { + if (!gtx.EE) return; + switch (resp.event) { + case "oncreate": + tabid = resp.tabId; + break; + case "onclose": + ret.onclose && ret.onclose(); + ret.closed = true; + gtx.EE.removeAllListeners("GM_openInTab:" + id); + break; + default: + LoggerCore.logger().warn("GM_openInTab resp is error", { + resp, + }); + break; + } + }); + } else { + ret.onclose && ret.onclose(); + ret.closed = true; + } + }); - @GMContext.API() - public GM_saveTab(tabData: object): void { - if (this.isInvalidContext()) return; - if (typeof tabData === "object") { - tabData = customClone(tabData); - } - this.sendMessage("GM_saveTab", [tabData]); - } + return ret; + }, - @GMContext.API({ depend: ["GM_saveTab"] }) - public "GM.saveTab"(tabData: object): Promise { - return new Promise((resolve) => { - this.GM_saveTab(tabData); - resolve(); - }); - } + "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { + return new Promise((resolve) => { + const ret = GM_openInTab!(url, param); + resolve(ret); + }); + }, + }), - @GMContext.API() - public GM_getTabs(callback: (tabsData: { [key: number]: object }) => any) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTabs", []).then((tabsData) => { - callback(tabsData); - }); - } + ...(hasGrant(scriptGrants, "GM_closeInTab", "GM.closeInTab") && { + // ScriptCat 额外API + GM_closeInTab(tabid: string) { + if (gtx.isInvalidContext()) return; + return gtx.sendMessage("GM_closeInTab", [tabid]); + }, + "GM.closeInTab"(tabid: string) { + if (gtx.isInvalidContext()) return; + return gtx.sendMessage("GM_closeInTab", [tabid]); + }, + }), - @GMContext.API({ depend: ["GM_getTabs"] }) - public "GM.getTabs"(): Promise<{ [key: number]: object }> { - return new Promise<{ [key: number]: object }>((resolve) => { - this.GM_getTabs((tabsData) => { - resolve(tabsData); - }); - }); - } + ...(hasGrant(scriptGrants, "GM.getTab", "GM_getTab") && { + GM_getTab(callback: (tabData: object) => void) { + if (gtx.isInvalidContext()) return; + gtx.sendMessage("GM_getTab", []).then((tabData) => { + callback(tabData ?? {}); + }); + }, - @GMContext.API() - public GM_setClipboard(data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { - if (this.isInvalidContext()) return; - // 物件参数意义不明。日后再检视特殊处理 - // 未支持 TM4.19+ application/octet-stream - // 参考: https://github.com/Tampermonkey/tampermonkey/issues/1250 - let mimetype: string | undefined; - if (typeof info === "object" && info?.mimetype) { - mimetype = info.mimetype; - } else { - mimetype = (typeof info === "string" ? info : info?.type) || "text/plain"; - if (mimetype === "text") mimetype = "text/plain"; - else if (mimetype === "html") mimetype = "text/html"; - } - data = `${data}`; // 强制 string type - this.sendMessage("GM_setClipboard", [data, mimetype]) - .then(() => { - if (typeof cb === "function") { - cb(); - } - }) - .catch(() => { - if (typeof cb === "function") { - cb(); + "GM.getTab"(): Promise { + return new Promise((resolve) => { + GM_getTab!((data) => { + resolve(data); + }); + }); + }, + }), + + ...(hasGrant(scriptGrants, "GM.saveTab", "GM_saveTab") && { + GM_saveTab(tabData: object): void { + if (gtx.isInvalidContext()) return; + if (typeof tabData === "object") { + tabData = customClone(tabData); } - }); - } + gtx.sendMessage("GM_saveTab", [tabData]); + }, - @GMContext.API({ depend: ["GM_setClipboard"] }) - public "GM.setClipboard"(data: string, info?: string | { type?: string; mimetype?: string }): Promise { - if (this.isInvalidContext()) return new Promise(() => {}); - return new Promise((resolve) => { - this.GM_setClipboard(data, info, () => { - resolve(); - }); - }); - } + "GM.saveTab"(tabData: object): Promise { + return new Promise((resolve) => { + GM_saveTab!(tabData); + resolve(); + }); + }, + }), - @GMContext.API() - public GM_getResourceText(name: string): string | undefined { - const r = this.scriptRes?.resource?.[name]; - if (r) { - return r.content; - } - return undefined; - } + ...(hasGrant(scriptGrants, "GM.getTabs", "GM_getTabs") && { + GM_getTabs(callback: (tabsData: { [key: number]: object }) => any) { + if (gtx.isInvalidContext()) return; + gtx.sendMessage("GM_getTabs", []).then((tabsData) => { + callback(tabsData); + }); + }, - @GMContext.API({ depend: ["GM_getResourceText"] }) - public "GM.getResourceText"(name: string): Promise { - // Asynchronous wrapper for GM_getResourceText to support GM.getResourceText - return new Promise((resolve) => { - const ret = this.GM_getResourceText(name); - resolve(ret); - }); - } + "GM.getTabs"(): Promise<{ [key: number]: object }> { + return new Promise<{ [key: number]: object }>((resolve) => { + GM_getTabs!((tabsData) => { + resolve(tabsData); + }); + }); + }, + }), + + ...(hasGrant(scriptGrants, "GM.setClipboard", "GM_setClipboard") && { + GM_setClipboard(data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { + if (gtx.isInvalidContext()) return; + // 物件参数意义不明。日后再检视特殊处理 + // 未支持 TM4.19+ application/octet-stream + // 参考: https://github.com/Tampermonkey/tampermonkey/issues/1250 + let mimetype: string | undefined; + if (typeof info === "object" && info?.mimetype) { + mimetype = info.mimetype; + } else { + mimetype = (typeof info === "string" ? info : info?.type) || "text/plain"; + if (mimetype === "text") mimetype = "text/plain"; + else if (mimetype === "html") mimetype = "text/html"; + } + data = `${data}`; // 强制 string type + gtx + .sendMessage("GM_setClipboard", [data, mimetype]) + .then(() => { + if (typeof cb === "function") { + cb(); + } + }) + .catch(() => { + if (typeof cb === "function") { + cb(); + } + }); + }, - @GMContext.API() - public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { - const r = this.scriptRes?.resource?.[name]; - if (r) { - let base64 = r.base64; - if (!base64) { - // 没有base64的话,则使用content转化 - base64 = `data:${r.contentType};base64,${strToBase64(r.content)}`; - } - if (isBlobUrl) { - return URL.createObjectURL(base64ToBlob(base64)); - } - return base64; - } - return undefined; - } + "GM.setClipboard"(data: string, info?: string | { type?: string; mimetype?: string }): Promise { + if (gtx.isInvalidContext()) return new Promise(() => {}); + return new Promise((resolve) => { + GM_setClipboard!(data, info, () => { + resolve(); + }); + }); + }, + }), - // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl - @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { - // Asynchronous wrapper for GM_getResourceURL to support GM.getResourceURL - return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); - resolve(ret); - }); - } + ...(hasGrant(scriptGrants, "GM_getResourceText", "GM.getResourceText") && { + GM_getResourceText(name: string): string | undefined { + const r = gtx.scriptRes?.resource?.[name]; + if (r) { + return r.content; + } + return undefined; + }, - @GMContext.API() - public "window.close"() { - return this.sendMessage("window.close", []); - } + "GM.getResourceText"(name: string): Promise { + // Asynchronous wrapper for GM_getResourceText to support GM.getResourceText + return new Promise((resolve) => { + const ret = GM_getResourceText!(name); + resolve(ret); + }); + }, + }), + + ...(hasGrant(scriptGrants, "GM_getResourceURL", "GM.getResourceUrl", "GM_getResourceUrl", "GM.getResourceURL") && { + GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { + const r = gtx.scriptRes?.resource?.[name]; + if (r) { + let base64 = r.base64; + if (!base64) { + // 没有base64的话,则使用content转化 + base64 = `data:${r.contentType};base64,${strToBase64(r.content)}`; + } + if (isBlobUrl) { + return URL.createObjectURL(base64ToBlob(base64)); + } + return base64; + } + return undefined; + }, - @GMContext.API() - public "window.focus"() { - return this.sendMessage("window.focus", []); - } + // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl + "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { + // Asynchronous wrapper for GM_getResourceURL to support GM.getResourceURL + return new Promise((resolve) => { + const ret = GM_getResourceURL!(name, isBlobUrl); + resolve(ret); + }); + }, + }), - @GMContext.protected() - apiLoadPromise: Promise | undefined; + ...(hasGrant(scriptGrants, "window.close") && { + "window.close"() { + return gtx.sendMessage("window.close", []); + }, + }), - @GMContext.API() - public CAT_scriptLoaded() { - return this.loadScriptPromise; - } -} + ...(hasGrant(scriptGrants, "window.focus") && { + "window.focus"() { + return gtx.sendMessage("window.focus", []); + }, + }), + + ...(hasGrant(scriptGrants, "CAT_scriptLoaded") && { + CAT_scriptLoaded() { + return gtx.loadScriptPromise; + }, + }), + } as const); + + return apis; +}; // 从 GM_Base 对象中解构出 createGMBase 函数并导出(可供其他模块使用) export const { createGMBase } = GM_Base; - -// 从 GMApi 对象中解构出内部函数,用于后续本地使用,不导出 -const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_download, _GM_notification } = GMApi; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index da4312886..ff987f9da 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -1,6 +1,6 @@ import { Native } from "../global"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; -import type GMApi from "./gm_api"; +import type { GM_Base } 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"; @@ -58,7 +58,7 @@ export type GMXHRResponseType = { export type GMXHRResponseTypeWithError = GMXHRResponseType & Required>; -export const toBlobURL = (a: GMApi, blob: Blob): Promise | string => { +export const toBlobURL = (a: GM_Base, blob: Blob): Promise | string => { // content_GMAPI 都应该在前台的内容脚本或真实页面执行。如果没有 typeof URL.createObjectURL 才使用信息传递交给后台 if (typeof URL.createObjectURL === "function") { return URL.createObjectURL(blob); @@ -95,7 +95,7 @@ export const convObjectToURL = async (object: string | URL | Blob | File | undef return url; }; -export const urlToDocumentInContentPage = async (a: GMApi, url: string, isContent: boolean) => { +export const urlToDocumentInContentPage = async (a: GM_Base, url: string, isContent: boolean) => { // url (e.g. blob url) -> XMLHttpRequest (CONTENT) -> Document (CONTENT) const nodeId = await a.sendMessage("CAT_fetchDocument", [url, isContent]); return (a.message).getAndDelRelatedTarget(nodeId) as Document; @@ -112,7 +112,7 @@ const getMimeType = (contentType: string) => { const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); export function GM_xmlhttpRequest( - a: GMApi, + a: GM_Base, details: GMTypes.XHRDetails, requirePromise: boolean, isDownload: boolean = false diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index acfdc137c..337afde85 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -1,10 +1,25 @@ import { type Script, ScriptDAO, type ScriptRunResource } from "@App/app/repo/scripts"; -import GMApi from "@App/app/service/content/gm_api/gm_api"; +import { createGMBase, createGMApis } 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"; import { mockNetwork } from "@Tests/mocks/network"; import { setMockNetworkResponse } from "@Tests/mocks/response"; +import { ListenerManager } from "@App/app/service/content/listener_manager"; +import EventEmitter from "eventemitter3"; +import type { Message } from "@Packages/message/types"; + +const createTestGMBase = (prefix: string, message: Message | undefined, scriptRes: ScriptRunResource | undefined) => { + return createGMBase({ + prefix, + message, + scriptRes, + valueChangeListener: new ListenerManager(), + EE: new EventEmitter(), + notificationTagMap: new Map(), + eventId: 0, + }); +}; const customXhrResponseMap = new Map< string, @@ -135,9 +150,12 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { addTestPermission(script.uuid); await new ScriptDAO().save(script); - const gmApi = new GMApi("serviceWorker", msg, { - uuid: script.uuid, - }); + const gmApi = createGMApis( + createTestGMBase("serviceWorker", msg, { + uuid: script.uuid, + }), + new Set(["GM_xmlhttpRequest"]) + ) as Required, "GM_xmlhttpRequest">>; it.concurrent("test GM xhr - plain text", async () => { const testUrl = "https://mock-xmlhttprequest-plain.test/"; customXhrResponseMap.set(testUrl, { @@ -341,9 +359,12 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { describe.concurrent("GM xmlHttpRequest", () => { const msg = initTestGMApi(); - const gmApi = new GMApi("serviceWorker", msg, { - uuid: script.uuid, - }); + const gmApi = createGMApis( + createTestGMBase("serviceWorker", msg, { + uuid: script.uuid, + }), + new Set(["GM_xmlhttpRequest"]) + ) as Required, "GM_xmlhttpRequest">>; it.concurrent("get", () => { return new Promise((resolve) => { gmApi.GM_xmlhttpRequest({ @@ -424,9 +445,12 @@ describe.concurrent("GM xmlHttpRequest", () => { describe("GM download", () => { const msg = initTestGMApi(); - const gmApi = new GMApi("serviceWorker", msg, { - uuid: script.uuid, - }); + const gmApi = createGMApis( + createTestGMBase("serviceWorker", msg, { + uuid: script.uuid, + }), + new Set(["GM_download"]) + ) as Required, "GM_download">>; it("simple download", async () => { const testUrl = "https://download.test/"; const originalBlob = new Blob(["download content"], { type: "text/plain" });