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) {