Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions example/tests/gm_add_element.js
Original file line number Diff line number Diff line change
@@ -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: '<div id="test777"></div>',
});

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");
26 changes: 24 additions & 2 deletions packages/message/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T = any>(eventType: string, detail: T) => {
if (detailClone && detail) detail = <T>detailClone(detail, performanceClone);
const ev = new CustomEventClone(eventType, {
detail,
cancelable: true,
Expand Down Expand Up @@ -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 = <T extends Record<string, TNestedPrimitive>>(
type: string,
eventInitDict: MouseEventInit | Omit<T, "movementX" | "relatedTarget">
) => {
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");
}
};
23 changes: 22 additions & 1 deletion packages/message/custom_event_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <Element>this.getAndDelRelatedTarget(id1);
const parent = <Node>this.getAndDelRelatedTarget(id2);
const refNode = id3 ? <Node>this.getAndDelRelatedTarget(id3) : null;
const attrs = (event.detail?.attrs ?? {}) as Record<string, string | number>;
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 = <DocumentFragment>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));
Expand Down
2 changes: 2 additions & 0 deletions src/app/service/content/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
126 changes: 107 additions & 19 deletions src/app/service/content/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -758,34 +760,120 @@ export default class GMApi extends GM_Base {
public GM_addElement(
parentNode: Node | string,
tagName: string | Record<string, string | number | boolean>,
attrs: Record<string, string | number | boolean> = {}
attrs: Record<string, string | number | boolean> | 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 = (<CustomEventMessage>this.message).sendRelatedTarget(parentNode);
parentNodeId = id;
sParentNode = parentNode as Node;
attrs = (attrs || {}) as Record<string, string | number | boolean>;
} else {
parentNodeId = null;
refNode = attrs as Node | null;
attrs = (tagName || {}) as Record<string, string | number | boolean>;
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 = (<CustomEventMessage>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 (<CustomEventMessage>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 = <Element>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<string, string | number>;
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"] })
Expand Down
33 changes: 0 additions & 33 deletions src/app/service/content/scripting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <Element>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) {
Expand Down
Loading