diff --git a/example/tests/gm_add_element.js b/example/tests/gm_add_element.js new file mode 100644 index 000000000..6f8499b6a --- /dev/null +++ b/example/tests/gm_add_element.js @@ -0,0 +1,106 @@ +// ==UserScript== +// @name GM_addElement test +// @match *://*/*?test_GM_addElement +// @grant GM_addElement +// @version 0 +// ==/UserScript== + +/* +### Example Sites +* https://content-security-policy.com/?test_GM_addElement (CSP) +* https://github.com/scriptscat/scriptcat/?test_GM_addElement (CSP) +* https://www.youtube.com/account_playback/?test_GM_addElement (TTP) +*/ + +const logSection = (title) => { + console.log(`\n=== ${title} ===`); +}; + +const logStep = (message, data) => { + if (data !== undefined) { + console.log(`→ ${message}:`, data); + } else { + console.log(`→ ${message}`); + } +}; + + +// ───────────────────────────────────────────── +// Native textarea insertion +// ───────────────────────────────────────────── +logSection("Native textarea insertion - BEGIN"); + +const textarea = GM_addElement('textarea', { + native: true, + value: "myText", +}); + +logStep("Textarea value", textarea.value); +logSection("Native textarea insertion - END"); + + +// ───────────────────────────────────────────── +// Div insertion +// ───────────────────────────────────────────── +logSection("Div insertion - BEGIN"); + +GM_addElement('div', { + innerHTML: '
', +}); + +logSection("Div insertion - END"); + + +// ───────────────────────────────────────────── +// Span insertion +// ───────────────────────────────────────────── +logSection("Span insertion - BEGIN"); + +GM_addElement(document.getElementById("test777"), 'span', { + className: "test777-span", + textContent: 'Hello World!', +}); + +logStep( + "Span content", + document.querySelector("span.test777-span").textContent +); + +logSection("Span insertion - END"); + + +// ───────────────────────────────────────────── +// Image insertion +// ───────────────────────────────────────────── +logSection("Image insertion - BEGIN"); + +let img; +await new Promise((resolve, reject) => { + img = GM_addElement(document.body, 'img', { + src: 'https://www.tampermonkey.net/favicon.ico', + onload: resolve, + onerror: reject + }); + + logStep("Image element inserted"); +}); + +logStep("Image loaded"); +logSection("Image insertion - END"); + + +// ───────────────────────────────────────────── +// Script insertion +// ───────────────────────────────────────────── +logSection("Script insertion - BEGIN"); + +GM_addElement(document.body, 'script', { + textContent: "window.myCustomFlag = true; console.log('script run ok');", +}, img); + +logStep( + "Script inserted before image", + img.previousSibling?.nodeName === "SCRIPT" +); + +logSection("Script insertion - END"); diff --git a/packages/message/common.ts b/packages/message/common.ts index 8b00e74cf..062692933 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -14,8 +14,8 @@ export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performance export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone); export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone); const detailClone = typeof cloneInto === "function" ? cloneInto : null; -export const pageDispatchCustomEvent = (eventType: string, detail: any) => { - if (detailClone && detail) detail = detailClone(detail, performanceClone); +export const pageDispatchCustomEvent = (eventType: string, detail: T) => { + if (detailClone && detail) detail = detailClone(detail, performanceClone); const ev = new CustomEventClone(eventType, { detail, cancelable: true, @@ -85,3 +85,25 @@ export const createMouseEvent = : (type: string, eventInitDict?: MouseEventInit | undefined): MouseEvent => { return new MouseEventClone(type, eventInitDict); }; + +type TPrimitive = string | number | boolean; +interface INestedPrimitive { + [key: string]: TPrimitive | INestedPrimitive; +} +type TNestedPrimitive = TPrimitive | INestedPrimitive; + +export const dispatchMyEvent = >( + type: string, + eventInitDict: MouseEventInit | Omit +) => { + let resFalse; + if ("movementX" in eventInitDict) { + resFalse = pageDispatchEvent(createMouseEvent(type, eventInitDict)); + } else { + resFalse = pageDispatchCustomEvent(type, eventInitDict); + } + if (resFalse !== false && eventInitDict.cancelable === true) { + // 通讯设置正确的话应不会发生 + throw new Error("Page Message Error"); + } +}; diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 5943bb632..084670db7 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -47,10 +47,31 @@ export class CustomEventMessage implements Message { this.receiveFlag = `${messageFlag}${isInbound ? DefinedFlags.inboundFlag : DefinedFlags.outboundFlag}${DefinedFlags.domEvent}`; this.sendFlag = `${messageFlag}${isInbound ? DefinedFlags.outboundFlag : DefinedFlags.inboundFlag}${DefinedFlags.domEvent}`; pageAddEventListener(this.receiveFlag, (event: Event) => { - if (event instanceof MouseEventClone && event.movementX === 0 && event.cancelable) { + if (event instanceof CustomEventClone && event.detail?.appendOrInsert === true) { + const id1 = event.detail?.id1 as number; + const id2 = event.detail?.id2 as number; + const id3 = event.detail?.id3 as number | undefined | null; + const el = this.getAndDelRelatedTarget(id1); + const parent = this.getAndDelRelatedTarget(id2); + const refNode = id3 ? this.getAndDelRelatedTarget(id3) : null; + const attrs = (event.detail?.attrs ?? {}) as Record; + const props = new Set(["textContent", "innerHTML", "innerText", "outerHTML", "className", "value"] as const); + for (const [key, value] of Object.entries(attrs)) { + if (props.has(key as any)) (el as any)[key] = value; + else el.setAttribute(key, value as string); + } + refNode ? parent.insertBefore(el, refNode) : parent.appendChild(el); + event.preventDefault(); + } else if (event instanceof CustomEventClone && typeof event.detail?.createElement === "string") { + const id0 = event.detail?.id0 as number; + const frag = this.getAndDelRelatedTarget(id0); + frag.appendChild(document.createElement(event.detail?.createElement)); + event.preventDefault(); + } else if (event instanceof MouseEventClone && event.movementX === 0 && event.cancelable) { event.preventDefault(); // 告知另一端这边已准备好 this.readyWrap.setReady(); // 两端已准备好,则 setReady() } else if (event instanceof MouseEventClone && event.movementX && event.relatedTarget) { + if (event.cancelable) event.preventDefault(); // 告知另一端 relatedTargetMap.set(event.movementX, event.relatedTarget); } else if (event instanceof CustomEventClone) { this.messageHandle(event.detail, new CustomEventPostMessage(this)); diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index e932f8c3c..5562212c6 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -7,6 +7,8 @@ export const Native = { structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, jsonStringify: JSON.stringify.bind(JSON), jsonParse: JSON.parse.bind(JSON), + createElement: Document.prototype.createElement, + ownFragment: new DocumentFragment(), } as const; export const customClone = (o: any) => { diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 874c4c1ff..25003d00a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -18,13 +18,15 @@ 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 { dispatchMyEvent, isContent } from "@Packages/message/common"; import { getStorageName } from "@App/pkg/utils/utils"; import { 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"; import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; +import { DefinedFlags } from "../../service_worker/runtime.consts"; +import { ScriptEnvTag } from "@Packages/message/consts"; // 内部函数呼叫定义 export interface IGM_Base { @@ -758,34 +760,120 @@ export default class GMApi extends GM_Base { public GM_addElement( parentNode: Node | string, tagName: string | Record, - attrs: Record = {} + attrs: Record | Node | null = {}, + refNode: Node | null = null ): Element | undefined { if (!this.message || !this.scriptRes) return; // 与content页的消息通讯实际是同步,此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise - let parentNodeId: number | null; + // 在content脚本执行的话,与直接 DOM 无异 + // TrustedTypes 限制了对 DOM 的 innerHTML/outerHTML 的操作 (TrustedHTML) + // TrustedTypes 限制了对 script 的 innerHTML/outerHTML/textContent/innerText 的操作 (TrustedScript) + // CSP 限制了对 appendChild/insertChild/replaceChild/insertAdjacentElement ... 等DOM插入移除操作 + + // let parentNodeId: number | null; + let sParentNode: Node | null = null; if (typeof parentNode !== "string") { - const id = (this.message).sendRelatedTarget(parentNode); - parentNodeId = id; + sParentNode = parentNode as Node; + attrs = (attrs || {}) as Record; } else { - parentNodeId = null; + refNode = attrs as Node | 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], - }, - }); - if (resp.code) { - throw new Error(resp.message); + + // 决定 parentNode + if (!sParentNode) { + sParentNode = document.head || document.body || document.documentElement || document.querySelector("*"); + // MV3 应该都至少有一个元素 (document.documentElement), 这个错误应该不会发生 + if (!sParentNode) throw new Error("Page Element Error"); } - return (this.message).getAndDelRelatedTarget(resp.data) as Element; + + refNode = refNode instanceof Node && refNode.parentNode === sParentNode ? refNode : null; + + // 不需要 incremental. 这个值只是在用来作一次性同步处理 + // 最小值为 1000000000 避免与其他 related Id 操作冲突 + let randInt = Math.floor(Math.random() * 1147483647 + 1000000000); // 32-bit signed int + randInt -= randInt % 100; // 用此方法可以生成不重复的 id + + const id0 = randInt; + const id1 = randInt + 1; + const id2 = randInt + 2; + let id3; + + // 目前未有直接取得 eventFlag 的方法。通过 page/content 的 receiveFlag 反推 eventFlag + const eventFlag = (this.message as CustomEventMessage).receiveFlag + .split(`${DefinedFlags.outboundFlag}${DefinedFlags.domEvent}`)[0] + .slice(0, -2); + + // content 的 receiveFlag + const ctReceiveFlag = `${eventFlag}${ScriptEnvTag.content}${DefinedFlags.outboundFlag}${DefinedFlags.domEvent}`; + + let el; + + const isNative = attrs.native === true; + if (isNative) { + // 直接使用页面的元素生成方法。某些情况例如 Custom Elements 用户可能需要直接在页面环境生成元素 + // CSP 或 TrustedTypes 目前未有对 document.createElement 做出任何限制。 + try { + el = Native.createElement.call(document, tagName as string); + } catch { + // 避免元素生成失败时无法执行。此情况应 fallback + console.warn("GM API: Native.createElement failed"); + } + } + if (!el) { + // 一般情况(非 isNative) 或 元素生成失败 (报错或回传null/undefined) + const frag = Native.ownFragment; + // 设置 fragment + dispatchMyEvent(ctReceiveFlag, { cancelable: true, movementX: id0, relatedTarget: frag }); + // 执行 createElement 并放入 fragment + dispatchMyEvent(ctReceiveFlag, { cancelable: true, createElement: `${tagName}`, id0: id0 }); + // 从 fragment 取回新增的 Element + el = frag.lastChild as Element | null; + // 如特殊情况导致无法创建元素,则报错。 + if (!el) throw new Error("GM API: createElement failed"); + } + + // 控制传送参数,避免参数出现 non-json-selizable + const attrsCT = {} as Record; + for (const [key, value] of Object.entries(attrs)) { + if (key === "native") continue; + if (typeof value === "string" || typeof value === "number") { + // 数字不是标准的 attribute value type, 但常见于实际使用 + attrsCT[key] = value; + } else { + // property setter for non attribute (e.g. Function, Symbol, boolean, etc) + // Function, Symbol 无法跨环境 + (el as any)[key] = value; + } + } + + // 设置 id1 -> el + dispatchMyEvent(ctReceiveFlag, { cancelable: true, movementX: id1, relatedTarget: el }); + + // 设置 id2 -> parentNode + dispatchMyEvent(ctReceiveFlag, { cancelable: true, movementX: id2, relatedTarget: sParentNode }); + + // 执行 attrsCT 设置并 appendChild + + if (refNode) { + id3 = randInt + 3; + // 设置 id3 -> parentNode + dispatchMyEvent(ctReceiveFlag, { cancelable: true, movementX: id3, relatedTarget: refNode }); + } + + dispatchMyEvent(ctReceiveFlag, { + cancelable: true, + appendOrInsert: true, + id1: id1, + id2: id2, + id3: id3, + attrs: attrsCT, + }); + + // 回传元素 + return el; } @GMContext.API({ depend: ["GM_addElement"] }) diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index dd24079ea..fa802ce52 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -95,39 +95,6 @@ export default class ScriptingRuntime { xhr.send(); }); } - case "GM_addElement": { - const [parentNodeId, tagName, tmpAttr, isContent] = data.params; - - // 根据来源选择不同的消息桥(content / inject) - const msg = isContent ? this.senderToContent : this.senderToInject; - - // 取回 parentNode(如果存在) - let parentNode: Node | undefined; - if (parentNodeId) { - parentNode = msg.getAndDelRelatedTarget(parentNodeId) as Node | undefined; - } - - // 创建元素并设置属性 - const el = document.createElement(tagName); - const attr = tmpAttr ? { ...tmpAttr } : {}; - let textContent = ""; - if (attr.textContent) { - textContent = attr.textContent; - delete attr.textContent; - } - for (const key of Object.keys(attr)) { - el.setAttribute(key, attr[key]); - } - if (textContent) el.textContent = textContent; - - // 优先挂到 parentNode,否则挂到 head/body/任意节点 - const node = parentNode || document.head || document.body || document.querySelector("*"); - node.appendChild(el); - - // 返回节点引用 id,供另一侧再取回 - const nodeId = msg.sendRelatedTarget(el); - return nodeId; - } case "GM_log": // 拦截 GM_log:直接打印到控制台(某些页面可能劫持 console.log) switch (data.params.length) {