添加GM element API

This commit is contained in:
王一之 2025-04-11 17:40:25 +08:00
parent a2870eb18e
commit 7ca85801ef
15 changed files with 393 additions and 128 deletions

View File

@ -17,7 +17,7 @@ export class CustomEventMessage implements Message {
EE: EventEmitter = new EventEmitter();
// 关联dom目标
relatedTarget: Map<number, Document> = new Map();
relatedTarget: Map<number, EventTarget> = new Map();
constructor(
protected flag: string,
@ -25,7 +25,7 @@ export class CustomEventMessage implements Message {
) {
window.addEventListener((isContent ? "ct" : "fd") + flag, (event) => {
if (event instanceof MouseEvent) {
this.relatedTarget.set(event.clientX, <Document>event.relatedTarget);
this.relatedTarget.set(event.clientX, event.relatedTarget!);
return;
} else if (event instanceof CustomEvent) {
this.messageHandle(event.detail, new CustomEventPostMessage(this));
@ -82,23 +82,7 @@ export class CustomEventMessage implements Message {
});
}
nativeSend(data: any) {
let detail = data;
// 特殊处理relatedTarget
if (detail.data && typeof detail.data.relatedTarget === "object") {
// 先将relatedTarget转换成id发送过去
const target = detail.data.relatedTarget;
delete detail.data.relatedTarget;
detail.data.relatedTarget = Math.ceil(Math.random() * 1000000);
// 可以使用此种方式交互element
const ev = new MouseEvent((this.isContent ? "fd" : "ct") + this.flag, {
clientX: detail.data.relatedTarget,
relatedTarget: target,
});
window.dispatchEvent(ev);
}
nativeSend(detail: any) {
if (typeof cloneInto !== "undefined") {
try {
LoggerCore.logger().info("nativeSend");
@ -131,6 +115,40 @@ export class CustomEventMessage implements Message {
});
}
// 同步发送消息
// 与content页的消息通讯实际是同步,此方法不需要经过background
// 但是请注意中间不要有promise
syncSendMessage(data: any): any {
const body: WindowMessageBody = {
messageId: uuidv4(),
type: "sendMessage",
data,
};
let ret: any;
const callback = (body: WindowMessageBody) => {
this.EE.removeListener("response:" + body.messageId, callback);
ret = body.data;
};
this.EE.addListener("response:" + body.messageId, callback);
this.nativeSend(body);
return ret;
}
relateId = 0;
sendRelatedTarget(target: EventTarget): number {
// 特殊处理relatedTarget返回id进行关联
// 先将relatedTarget转换成id发送过去
const id = ++this.relateId;
// 可以使用此种方式交互element
const ev = new MouseEvent((this.isContent ? "fd" : "ct") + this.flag, {
clientX: id,
relatedTarget: target,
});
window.dispatchEvent(ev);
return id;
}
getAndDelRelatedTarget(id: number) {
const target = this.relatedTarget.get(id);
this.relatedTarget.delete(id);

View File

@ -1,5 +1,5 @@
import LoggerCore from "@App/app/logger/core";
import { connect } from "./client";
import { connect, sendMessage } from "./client";
export interface Message extends MessageSend {
onConnect(callback: (data: any, con: MessageConnect) => void): void;
@ -20,6 +20,12 @@ export interface MessageConnect {
export type MessageSender = chrome.runtime.MessageSender;
export type ExtMessageSender = {
tabId: number;
frameId?: number;
documentId?: string;
};
export class GetSender {
constructor(private sender: MessageConnect | MessageSender) {}
@ -27,12 +33,22 @@ export class GetSender {
return this.sender as MessageSender;
}
getExtMessageSender(): ExtMessageSender {
const sender = this.sender as MessageSender;
return {
tabId: sender.tab?.id || -1, // -1表示后台脚本
frameId: sender.frameId,
documentId: sender.documentId,
};
}
getConnect(): MessageConnect {
return this.sender as MessageConnect;
}
}
export type ApiFunction = (params: any, con: GetSender) => Promise<any> | void;
export type ApiFunctionSync = (params: any, con: GetSender) => any;
export class Server {
private apiFunctionMap: Map<string, ApiFunction> = new Map();
@ -115,11 +131,24 @@ export class Group {
}
// 转发消息
export function forwardMessage(prefix: string, path: string, from: Server, to: MessageSend, middleware?: ApiFunction) {
from.on(path, async (params, fromCon) => {
export function forwardMessage(
prefix: string,
path: string,
from: Server,
to: MessageSend,
middleware?: ApiFunctionSync
) {
from.on(path, (params, fromCon) => {
if (middleware) {
const resp = await middleware(params, fromCon);
if (resp !== false) {
// 此处是为了处理CustomEventMessage的同步消息情况
const resp = middleware(params, fromCon) as any;
if (resp instanceof Promise) {
return resp.then((data) => {
if (data !== false) {
return data;
}
});
} else if (resp !== false) {
return resp;
}
}
@ -140,7 +169,7 @@ export function forwardMessage(prefix: string, path: string, from: Server, to: M
});
});
} else {
return to.sendMessage({ action: prefix + "/" + path, data: params });
return sendMessage(to, prefix + "/" + path, params);
}
});
}

View File

@ -1,5 +1,6 @@
import { ScriptRunResouce } from "@App/app/repo/scripts";
import { Client, sendMessage } from "@Packages/message/client";
import { CustomEventMessage } from "@Packages/message/custom_event_message";
import { forwardMessage, Message, MessageSend, Server } from "@Packages/message/server";
// content页的处理
@ -52,6 +53,32 @@ export default class ContentRuntime {
xhr.send();
});
}
case "GM_addElement": {
let [parentNodeId, tagName, attr] = data.params;
let parentNode: EventTarget | undefined;
if (parentNodeId) {
parentNode = (this.msg as CustomEventMessage).getAndDelRelatedTarget(parentNodeId);
}
const el = <Element>document.createElement(tagName);
Object.keys(attr).forEach((key) => {
el.setAttribute(key, attr[key]);
});
let textContent = "";
if (attr) {
if (attr.textContent) {
textContent = attr.textContent;
delete attr.textContent;
}
} else {
attr = {};
}
if (textContent) {
el.innerHTML = textContent;
}
(<Element>parentNode || document.head || document.body || document.querySelector("*")).appendChild(el);
const nodeId = (this.msg as CustomEventMessage).sendRelatedTarget(el);
return nodeId;
}
}
return Promise.resolve(false);
}

View File

@ -4,6 +4,7 @@ import { ScriptRunResouce } from "@App/app/repo/scripts";
import GMApi from "./gm_api";
import { compileScript, createContext, proxyContext, ScriptFunc } from "./utils";
import { Message } from "@Packages/message/server";
import { EmitEventRequest } from "../service_worker/runtime";
export type ValueUpdateSender = {
runFlag: string;
@ -69,12 +70,9 @@ export default class ExecScript {
}
}
emitEvent(event: string, data: any) {
switch (event) {
case "menuClick":
this.sandboxContent?.menuClick(data);
break;
}
emitEvent(event: string, eventId: string, data: any) {
this.logger.debug("emit event", { event, eventId, data });
this.sandboxContent?.emitEvent(event, eventId, data);
}
valueUpdate(data: ValueUpdateData) {

View File

@ -1,5 +1,5 @@
import { ScriptRunResouce } from "@App/app/repo/scripts";
import { getMetadataStr, getUserConfigStr, parseUserConfig } from "@App/pkg/utils/script";
import { base64ToBlob, getMetadataStr, getUserConfigStr, parseUserConfig } from "@App/pkg/utils/script";
import { ValueUpdateData } from "./exec_script";
import { ExtVersion } from "@App/app/const";
import { Message, MessageConnect } from "@Packages/message/server";
@ -11,7 +11,6 @@ import { getStorageName } from "@App/pkg/utils/utils";
interface ApiParam {
depend?: string[];
listener?: () => void;
}
export interface ApiValue {
@ -25,9 +24,6 @@ export class GMContext {
public static API(param: ApiParam = {}) {
return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
const key = propertyName;
if (param.listener) {
param.listener();
}
if (key === "GMdotXmlHttpRequest") {
GMContext.apis.set("GM.xmlHttpRequest", {
api: descriptor.value,
@ -103,8 +99,8 @@ export default class GMApi {
}
}
menuClick(id: number) {
this.EE.emit("menuClick" + id);
emitEvent(event: string, eventId: string, data: any) {
this.EE.emit(event + ":" + eventId, data);
}
// 获取脚本信息和管理器信息
@ -239,19 +235,71 @@ export default class GMApi {
this.eventId += 1;
const id = this.eventId;
this.menuMap.set(id, name);
this.EE.addListener("menuClick" + id, listener);
this.EE.addListener("menuClick:" + id, listener);
this.sendMessage("GM_registerMenuCommand", [id, name, accessKey]);
return id;
}
@GMContext.API()
GM_addStyle(css: string) {
// 与content页的消息通讯实际是同步,此方法不需要经过background
// 这里直接使用同步的方式去处理, 不要有promise
const resp = (<CustomEventMessage>this.message).syncSendMessage({
action: this.prefix + "/runtime/gmApi",
data: {
uuid: this.scriptRes.uuid,
api: "GM_addElement",
params: [
null,
"style",
{
textContent: css,
},
],
},
});
if (resp.code !== 0) {
throw new Error(resp.message);
}
return (<CustomEventMessage>this.message).getAndDelRelatedTarget(resp.data);
}
@GMContext.API()
GM_addElement(parentNode: EventTarget | string, tagName: any, attrs?: any) {
// 与content页的消息通讯实际是同步,此方法不需要经过background
// 这里直接使用同步的方式去处理, 不要有promise
let parentNodeId: any = parentNode;
if (typeof parentNodeId !== "string") {
const id = (<CustomEventMessage>this.message).sendRelatedTarget(parentNodeId);
parentNodeId = id;
} else {
parentNodeId = null;
}
const resp = (<CustomEventMessage>this.message).syncSendMessage({
action: this.prefix + "/runtime/gmApi",
data: {
uuid: this.scriptRes.uuid,
api: "GM_addElement",
params: [
parentNodeId,
typeof parentNode === "string" ? parentNode : tagName,
typeof parentNode === "string" ? tagName : attrs,
],
},
});
if (resp.code !== 0) {
throw new Error(resp.message);
}
return (<CustomEventMessage>this.message).getAndDelRelatedTarget(resp.data);
}
@GMContext.API()
GM_unregisterMenuCommand(id: number): void {
console.log("unregisterMenuCommand", id);
if (!this.menuMap) {
this.menuMap = new Map();
}
this.menuMap.delete(id);
this.EE.removeAllListeners("menuClick" + id);
this.EE.removeAllListeners("menuClick:" + id);
this.sendMessage("GM_unregisterMenuCommand", [id]);
}
@ -449,15 +497,19 @@ export default class GMApi {
};
}
@GMContext.API()
@GMContext.API({
depend: ["GM_closeNotification", "GM_updateNotification"],
})
public async GM_notification(
detail: GMTypes.NotificationDetails | string,
ondone?: GMTypes.NotificationOnDone | string,
image?: string,
onclick?: GMTypes.NotificationOnClick
) {
let data: GMTypes.NotificationDetails = {};
this.eventId += 1;
let data: GMTypes.NotificationDetails;
if (typeof detail === "string") {
data = {};
data.text = detail;
switch (arguments.length) {
case 4:
@ -470,7 +522,7 @@ export default class GMApi {
break;
}
} else {
data = detail;
data = Object.assign({}, detail);
data.ondone = data.ondone || <GMTypes.NotificationOnDone>ondone;
}
let click: GMTypes.NotificationOnClick;
@ -488,20 +540,20 @@ export default class GMApi {
create = data.oncreate;
delete data.oncreate;
}
this.eventId += 1;
this.sendMessage("GM_notification", [data]);
this.EE.addListener("GM_notification:" + this.eventId, (resp: any) => {
this.sendMessage("GM_notification", [data]).then((id) => {
if (create) {
create.apply({ id }, [id]);
}
this.EE.addListener("GM_notification:" + id, (resp: any) => {
switch (resp.event) {
case "click": {
click && click.apply({ id: resp.id }, [resp.id, resp.index]);
case "click":
case "buttonClick": {
click && click.apply({ id }, [id, resp.params.index]);
break;
}
case "done": {
done && done.apply({ id: resp.id }, [resp.user]);
break;
}
case "create": {
create && create.apply({ id: resp.id }, [resp.id]);
case "close": {
done && done.apply({ id }, [resp.params.byUser]);
this.EE.removeAllListeners("GM_notification:" + this.eventId);
break;
}
default:
@ -511,5 +563,31 @@ export default class GMApi {
break;
}
});
});
}
@GMContext.API()
public GM_closeNotification(id: string): void {
this.sendMessage("GM_closeNotification", [id]);
}
@GMContext.API()
public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void {
this.sendMessage("GM_updateNotification", [id, details]);
}
@GMContext.API()
GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined {
if (!this.scriptRes.resource) {
return undefined;
}
const r = this.scriptRes.resource[name];
if (r) {
if (isBlobUrl) {
return URL.createObjectURL(base64ToBlob(r.base64));
}
return r.base64;
}
return undefined;
}
}

View File

@ -34,7 +34,7 @@ export class InjectRuntime {
// 转发给脚本
const exec = this.execList.find((val) => val.scriptRes.uuid === data.uuid);
if (exec) {
exec.emitEvent(data.event, data.data);
exec.emitEvent(data.event, data.eventId, data.data);
}
});
this.server.on("runtime/valueUpdate", (data: ValueUpdateData) => {

View File

@ -66,7 +66,7 @@ export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, envPrefi
runFlag: uuidv4(),
eventId: 10000,
valueUpdate: GMApi.prototype.valueUpdate,
menuClick: GMApi.prototype.menuClick,
emitEvent: GMApi.prototype.emitEvent,
EE: new EventEmitter(),
GM: { Info: GMInfo },
GM_info: GMInfo,

View File

@ -305,7 +305,7 @@ export class Runtime {
// 转发给脚本
const exec = this.execScripts.get(data.uuid);
if (exec) {
exec.emitEvent(data.event, data.data);
exec.emitEvent(data.event, data.eventId, data.data);
}
}

View File

@ -119,9 +119,11 @@ export class PopupClient extends Client {
return this.do("menuClick", {
uuid,
id: data.id,
sender: {
tabId: data.tabId,
frameId: data.frameId,
documentId: data.documentId,
},
});
}
}

View File

@ -1,7 +1,7 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import { GetSender, Group, MessageSend } from "@Packages/message/server";
import { ExtMessageSender, GetSender, Group, MessageSend } from "@Packages/message/server";
import { ValueService } from "@App/app/service/service_worker/value";
import PermissionVerify from "./permission_verify";
import { connect } from "@Packages/message/client";
@ -9,6 +9,7 @@ import Cache, { incr } from "@App/app/cache";
import EventEmitter from "eventemitter3";
import { MessageQueue } from "@Packages/message/message_queue";
import { RuntimeService } from "./runtime";
import { getIcon, isFirefox } from "@App/pkg/utils/utils";
// GMApi,处理脚本的GM API调用请求
@ -52,6 +53,12 @@ export const unsafeHeaders: { [key: string]: boolean } = {
via: true,
};
type NotificationData = {
uuid: string;
details: GMTypes.NotificationDetails;
sender: ExtMessageSender;
};
export type Api = (request: Request, con: GetSender) => Promise<any>;
export default class GMApi {
@ -71,7 +78,7 @@ export default class GMApi {
this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" });
}
async handlerRequest(data: MessageRequest, con: GetSender) {
async handlerRequest(data: MessageRequest, sender: GetSender) {
this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params });
const api = PermissionVerify.apis.get(data.api);
if (!api) {
@ -84,7 +91,7 @@ export default class GMApi {
this.logger.error("verify error", { api: data.api }, Logger.E(e));
return Promise.reject(e);
}
return api.api.call(this, req, con);
return api.api.call(this, req, sender);
}
// 解析请求
@ -240,6 +247,105 @@ export default class GMApi {
});
}
@PermissionVerify.API({})
GM_notification(request: Request, sender: GetSender) {
if (request.params.length === 0) {
return Promise.reject(new Error("param is failed"));
}
const details: GMTypes.NotificationDetails = request.params[0];
const options: chrome.notifications.NotificationOptions<true> = {
title: details.title || "ScriptCat",
message: details.text || "无消息内容",
iconUrl: details.image || getIcon(request.script) || chrome.runtime.getURL("assets/logo.png"),
type: isFirefox() || details.progress === undefined ? "basic" : "progress",
};
if (!isFirefox()) {
options.silent = details.silent;
options.buttons = details.buttons;
}
options.progress = options.progress && parseInt(details.progress as any, 10);
return new Promise((resolve) => {
chrome.notifications.create(options, (notificationId) => {
Cache.getInstance().set(`GM_notification:${notificationId}`, {
uuid: request.script.uuid,
details: details,
sender: sender.getExtMessageSender(),
});
if (details.timeout) {
setTimeout(() => {
chrome.notifications.clear(notificationId);
Cache.getInstance().del(`GM_notification:${notificationId}`);
}, details.timeout);
}
resolve(notificationId);
});
});
}
@PermissionVerify.API({
link: "GM_notification",
})
GM_closeNotification(request: Request) {
if (request.params.length === 0) {
return Promise.reject(new Error("param is failed"));
}
const [notificationId] = request.params;
Cache.getInstance().del(`GM_notification:${notificationId}`);
chrome.notifications.clear(notificationId);
}
@PermissionVerify.API({
link: "GM_notification",
})
GM_updateNotification(request: Request) {
if (isFirefox()) {
return Promise.reject(new Error("firefox does not support this method"));
}
const id = request.params[0];
const details: GMTypes.NotificationDetails = request.params[1];
const options: chrome.notifications.NotificationOptions = {
title: details.title,
message: details.text,
iconUrl: details.image,
type: details.progress === undefined ? "basic" : "progress",
silent: details.silent,
progress: details.progress && parseInt(details.progress as any, 10),
};
chrome.notifications.update(<string>id, options);
}
handlerNotification() {
const send = async (event: string, notificationId: string, params?: any) => {
const ret = (await Cache.getInstance().get(`GM_notification:${notificationId}`)) as NotificationData;
if (ret) {
this.runtime.emitEventToTab(ret.sender, {
event: "GM_notification",
eventId: notificationId,
uuid: ret.uuid,
data: {
event,
params,
},
});
}
};
chrome.notifications.onClosed.addListener((notificationId, byUser) => {
send("close", notificationId, {
byUser,
});
Cache.getInstance().del(`GM_notification:${notificationId}`);
});
chrome.notifications.onClicked.addListener((notificationId) => {
send("click", notificationId);
});
chrome.notifications.onButtonClicked.addListener((notificationId, index) => {
send("buttonClick", notificationId, {
index: index,
});
});
}
// 处理GM_xmlhttpRequest请求
handlerGmXhr() {
chrome.webRequest.onBeforeSendHeaders.addListener(
@ -290,5 +396,6 @@ export default class GMApi {
start() {
this.group.on("gmApi", this.handlerRequest.bind(this));
this.handlerGmXhr();
this.handlerNotification();
}
}

View File

@ -1,5 +1,5 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { Group } from "@Packages/message/server";
import { ExtMessageSender, Group } from "@Packages/message/server";
import { RuntimeService, ScriptMatchInfo } from "./runtime";
import Cache from "@App/app/cache";
import { GetPopupDataReq, GetPopupDataRes } from "./client";
@ -315,32 +315,13 @@ export class PopupService {
});
}
menuClick({
uuid,
id,
tabId,
frameId,
documentId,
}: {
uuid: string;
id: number;
tabId: number;
frameId: number;
documentId: string;
}) {
menuClick({ uuid, id, sender }: { uuid: string; id: number; sender: ExtMessageSender }) {
// 菜单点击事件
this.runtime.EmitEventToTab(
tabId,
{
this.runtime.emitEventToTab(sender, {
uuid,
event: "menuClick",
data: id,
},
{
frameId,
documentId: documentId,
}
);
eventId: id.toString(),
});
return Promise.resolve(true);
}
@ -384,9 +365,11 @@ export class PopupService {
this.menuClick({
uuid: script.uuid,
id: menuItem.id,
sender: {
tabId: bgscript ? -1 : tab!.id!,
frameId: menuItem.frameId || 0,
documentId: menuItem.documentId || "",
},
});
return;
}

View File

@ -1,5 +1,5 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { GetSender, Group, MessageSend } from "@Packages/message/server";
import { ExtMessageSender, GetSender, Group, MessageSend } from "@Packages/message/server";
import {
Script,
SCRIPT_STATUS,
@ -32,7 +32,8 @@ export interface ScriptMatchInfo extends ScriptRunResouce {
export interface EmitEventRequest {
uuid: string;
event: string;
data: any;
eventId: string;
data?: any;
}
export class RuntimeService {
@ -122,36 +123,35 @@ export class RuntimeService {
}
// 给指定tab发送消息
sendMessageToTab(
tabId: number,
action: string,
data: any,
options?: {
documentId?: string;
frameId?: number;
}
) {
if (tabId === -1) {
sendMessageToTab(to: ExtMessageSender, action: string, data: any) {
if (to.tabId === -1) {
// 如果是-1, 代表给offscreen发送消息
return sendMessage(this.sender, "offscreen/runtime/" + action, data);
}
return sendMessage(new ExtensionContentMessageSend(tabId, options), "content/runtime/" + action, data);
return sendMessage(
new ExtensionContentMessageSend(to.tabId, {
documentId: to.documentId,
frameId: to.frameId,
}),
"content/runtime/" + action,
data
);
}
// 给指定脚本触发事件
EmitEventToTab(
tabId: number,
req: EmitEventRequest,
options?: {
documentId?: string;
frameId?: number;
}
) {
if (tabId === -1) {
emitEventToTab(to: ExtMessageSender, req: EmitEventRequest) {
if (to.tabId === -1) {
// 如果是-1, 代表给offscreen发送消息
return sendMessage(this.sender, "offscreen/runtime/emitEvent", req);
}
return sendMessage(new ExtensionContentMessageSend(tabId, options), "content/runtime/emitEvent", req);
return sendMessage(
new ExtensionContentMessageSend(to.tabId, {
documentId: to.documentId,
frameId: to.frameId,
}),
"content/runtime/emitEvent",
req
);
}
async getPageScriptUuidByUrl(url: string, includeCustomize?: boolean) {

View File

@ -73,12 +73,24 @@ export class ValueService {
tabs.forEach(async (tab) => {
const scriptMenu = await this.popup!.getScriptMenu(tab.id!);
if (scriptMenu.find((item) => item.storageName === storageName)) {
this.runtime!.sendMessageToTab(tab.id!, "valueUpdate", sendData);
this.runtime!.sendMessageToTab(
{
tabId: tab.id!,
},
"valueUpdate",
sendData
);
}
});
});
// 推送到offscreen中
sendMessage(this.send, "offscreen/runtime/valueUpdate", sendData);
this.runtime!.sendMessageToTab(
{
tabId: -1,
},
"valueUpdate",
sendData
);
return Promise.resolve(true);
}

View File

@ -30,6 +30,7 @@
"webRequest",
"userScripts",
"contextMenus",
"notifications",
"unlimitedStorage",
"declarativeNetRequest"
],

View File

@ -224,3 +224,13 @@ export function getStorageName(script: Script): string {
}
return script.uuid;
}
export function getIcon(script: Script): string | undefined {
return (
(script.metadata.icon && script.metadata.icon[0]) ||
(script.metadata.iconurl && script.metadata.iconurl[0]) ||
(script.metadata.defaulticon && script.metadata.defaulticon[0]) ||
(script.metadata.icon64 && script.metadata.icon64[0]) ||
(script.metadata.icon64url && script.metadata.icon64url[0])
);
}