749 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ScriptRunResouce } from "@App/app/repo/scripts";
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";
import { CustomEventMessage } from "@Packages/message/custom_event_message";
import LoggerCore from "@App/app/logger/core";
import { connect, sendMessage } from "@Packages/message/client";
import EventEmitter from "eventemitter3";
import { getStorageName } from "@App/pkg/utils/utils";
interface ApiParam {
depend?: string[];
}
export interface ApiValue {
api: any;
param: ApiParam;
}
export class GMContext {
static apis: Map<string, ApiValue> = new Map();
public static API(param: ApiParam = {}) {
return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
const key = propertyName;
if (key === "GMdotXmlHttpRequest") {
GMContext.apis.set("GM.xmlHttpRequest", {
api: descriptor.value,
param,
});
return;
}
GMContext.apis.set(key, {
api: descriptor.value,
param,
});
// 兼容GM.*
const dot = key.replace("_", ".");
if (dot !== key) {
// 特殊处理GM.xmlHttpRequest
if (dot === "GM.xmlhttpRequest") {
return;
}
GMContext.apis.set(dot, {
api: descriptor.value,
param,
});
}
};
}
}
export default class GMApi {
scriptRes!: ScriptRunResouce;
runFlag!: string;
valueChangeListener = new Map<number, { name: string; listener: GMTypes.ValueChangeListener }>();
constructor(
private prefix: string,
private message: Message
) {}
// 单次回调使用
public sendMessage(api: string, params: any[]) {
return sendMessage(this.message, this.prefix + "/runtime/gmApi", {
uuid: this.scriptRes.uuid,
api,
params,
});
}
// 长连接使用,connect只用于接受消息,不发送消息
public connect(api: string, params: any[]) {
return connect(this.message, this.prefix + "/runtime/gmApi", {
uuid: this.scriptRes.uuid,
api,
params,
});
}
public valueUpdate(data: ValueUpdateData) {
if (data.uuid === this.scriptRes.uuid || data.storageName === getStorageName(this.scriptRes)) {
// 触发,并更新值
if (data.value === undefined) {
if (this.scriptRes.value[data.key] !== undefined) {
delete this.scriptRes.value[data.key];
}
} else {
this.scriptRes.value[data.key] = data.value;
}
this.valueChangeListener.forEach((item) => {
if (item.name === data.key) {
item.listener(data.key, data.oldValue, data.value, data.sender.runFlag !== this.runFlag, data.sender.tabId);
}
});
}
}
emitEvent(event: string, eventId: string, data: any) {
this.EE.emit(event + ":" + eventId, data);
}
// 获取脚本信息和管理器信息
static GM_info(script: ScriptRunResouce) {
const metadataStr = getMetadataStr(script.code);
const userConfigStr = getUserConfigStr(script.code) || "";
const options = {
description: (script.metadata.description && script.metadata.description[0]) || null,
matches: script.metadata.match || [],
includes: script.metadata.include || [],
"run-at": (script.metadata["run-at"] && script.metadata["run-at"][0]) || "document-idle",
icon: (script.metadata.icon && script.metadata.icon[0]) || null,
icon64: (script.metadata.icon64 && script.metadata.icon64[0]) || null,
header: metadataStr,
grant: script.metadata.grant || [],
connects: script.metadata.connect || [],
};
return {
// downloadMode
// isIncognito
scriptWillUpdate: true,
scriptHandler: "ScriptCat",
scriptUpdateURL: script.downloadUrl,
scriptMetaStr: metadataStr,
userConfig: parseUserConfig(userConfigStr),
userConfigStr,
// scriptSource: script.sourceCode,
version: ExtVersion,
script: {
// TODO: 更多完整的信息(为了兼容Tampermonkey,后续待定)
name: script.name,
namespace: script.namespace,
version: script.metadata.version && script.metadata.version[0],
author: script.author,
...options,
},
};
}
// 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间
@GMContext.API()
public GM_getValue(key: string, defaultValue?: any) {
const ret = this.scriptRes.value[key];
if (ret) {
return ret;
}
return defaultValue;
}
@GMContext.API()
public GM_setValue(key: string, value: any) {
// 对object的value进行一次转化
if (typeof value === "object") {
value = JSON.parse(JSON.stringify(value));
}
if (value === undefined) {
delete this.scriptRes.value[key];
} else {
this.scriptRes.value[key] = value;
}
return this.sendMessage("GM_setValue", [key, value]);
}
@GMContext.API({ depend: ["GM_setValue"] })
public GM_deleteValue(name: string): void {
this.GM_setValue(name, undefined);
}
eventId: number = 0;
menuMap: Map<number, string> | undefined;
EE: EventEmitter = new EventEmitter();
@GMContext.API()
public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number {
this.eventId += 1;
this.valueChangeListener.set(this.eventId, { name, listener });
return this.eventId;
}
@GMContext.API()
public GM_removeValueChangeListener(listenerId: number): void {
this.valueChangeListener.delete(listenerId);
}
@GMContext.API()
public GM_listValues(): string[] {
return Object.keys(this.scriptRes.value);
}
@GMContext.API()
GM_log(message: string, level?: GMTypes.LoggerLevel, labels?: GMTypes.LoggerLabel) {
if (typeof message !== "string") {
message = JSON.stringify(message);
}
return this.sendMessage("GM_log", [message, level, labels]);
}
@GMContext.API()
public CAT_createBlobUrl(blob: Blob): Promise<string> {
return this.sendMessage("CAT_createBlobUrl", [blob]);
}
// 辅助GM_xml获取blob数据
@GMContext.API()
public CAT_fetchBlob(url: string): Promise<Blob> {
return this.sendMessage("CAT_fetchBlob", [url]);
}
@GMContext.API()
public async CAT_fetchDocument(url: string): Promise<Document | undefined> {
const data = await this.sendMessage("CAT_fetchDocument", [url]);
return (<CustomEventMessage>this.message).getAndDelRelatedTarget(data.relatedTarget) as Document;
}
@GMContext.API()
GM_cookie(
action: string,
details: GMTypes.CookieDetails,
done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void
) {
this.sendMessage("GM_cookie", [action, details])
.then((resp: any) => {
done && done(resp, undefined);
})
.catch((err) => {
done && done(undefined, err);
});
}
@GMContext.API()
GM_registerMenuCommand(name: string, listener: () => void, accessKey?: string): number {
if (!this.menuMap) {
this.menuMap = new Map();
}
let flag = 0;
this.menuMap.forEach((val, menuId) => {
if (val === name) {
flag = menuId;
}
});
if (flag) {
return flag;
}
this.eventId += 1;
const id = this.eventId;
this.menuMap.set(id, name);
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 {
if (!this.menuMap) {
this.menuMap = new Map();
}
this.menuMap.delete(id);
this.EE.removeAllListeners("menuClick:" + id);
this.sendMessage("GM_unregisterMenuCommand", [id]);
}
// 用于脚本跨域请求,需要@connect domain指定允许的域名
@GMContext.API({
depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"],
})
public GM_xmlhttpRequest(details: GMTypes.XHRDetails) {
const u = new URL(details.url, window.location.href);
if (details.headers) {
Object.keys(details.headers).forEach((key) => {
if (key.toLowerCase() === "cookie") {
details.cookie = details.headers![key];
delete details.headers![key];
}
});
}
const param: GMSend.XHRDetails = {
method: details.method,
timeout: details.timeout,
url: u.href,
headers: details.headers,
cookie: details.cookie,
context: details.context,
responseType: details.responseType,
overrideMimeType: details.overrideMimeType,
anonymous: details.anonymous,
user: details.user,
password: details.password,
maxRedirects: details.maxRedirects,
};
if (!param.headers) {
param.headers = {};
}
if (details.nocache) {
param.headers["Cache-Control"] = "no-cache";
}
let connect: MessageConnect;
const handler = async () => {
// 处理数据
if (details.data instanceof FormData) {
// 处理FormData
param.dataType = "FormData";
const data: Array<GMSend.XHRFormData> = [];
const keys: { [key: string]: boolean } = {};
details.data.forEach((val, key) => {
keys[key] = true;
});
// 处理FormData中的数据
await Promise.all(
Object.keys(keys).map((key) => {
const values = (<FormData>details.data).getAll(key);
return Promise.all(
values.map(async (val) => {
if (val instanceof File) {
const url = await this.CAT_createBlobUrl(val);
data.push({
key,
type: "file",
val: url,
filename: val.name,
});
} else {
data.push({
key,
type: "text",
val,
});
}
})
);
})
);
param.data = data;
} else if (details.data instanceof Blob) {
// 处理blob
param.dataType = "Blob";
param.data = await this.CAT_createBlobUrl(details.data);
} else {
param.data = details.data;
}
// 处理返回数据
let readerStream: ReadableStream<Uint8Array> | undefined;
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
// 如果返回类型是arraybuffer或者blob的情况下,需要将返回的数据转化为blob
// 在background通过URL.createObjectURL转化为url,然后在content页读取url获取blob对象
const responseType = details.responseType?.toLocaleLowerCase();
const warpResponse = (old: (xhr: GMTypes.XHRResponse) => void) => {
if (responseType === "stream") {
readerStream = new ReadableStream<Uint8Array>({
start(ctrl) {
controller = ctrl;
},
});
}
return async (xhr: GMTypes.XHRResponse) => {
if (xhr.response) {
if (responseType === "document") {
xhr.response = await this.CAT_fetchDocument(<string>xhr.response);
xhr.responseXML = xhr.response;
xhr.responseType = "document";
} else {
const resp = await this.CAT_fetchBlob(<string>xhr.response);
if (responseType === "arraybuffer") {
xhr.response = await resp.arrayBuffer();
} else {
xhr.response = resp;
}
}
}
if (responseType === "stream") {
xhr.response = readerStream;
}
old(xhr);
};
};
if (
responseType === "arraybuffer" ||
responseType === "blob" ||
responseType === "document" ||
responseType === "stream"
) {
if (details.onload) {
details.onload = warpResponse(details.onload);
}
if (details.onreadystatechange) {
details.onreadystatechange = warpResponse(details.onreadystatechange);
}
if (details.onloadend) {
details.onloadend = warpResponse(details.onloadend);
}
// document类型读取blob,然后在content页转化为document对象
if (responseType === "document") {
param.responseType = "blob";
}
if (responseType === "stream") {
if (details.onloadstart) {
details.onloadstart = warpResponse(details.onloadstart);
}
}
}
// 发送信息
this.connect("GM_xmlhttpRequest", [param]).then((con) => {
connect = con;
con.onMessage((data: { action: string; data: any }) => {
// 处理返回
switch (data.action) {
case "onload":
details.onload?.(data.data);
break;
case "onloadend":
details.onloadend?.(data.data);
break;
case "onloadstart":
details.onloadstart?.(data.data);
break;
case "onprogress":
details.onprogress?.(data.data);
break;
case "onreadystatechange":
details.onreadystatechange && details.onreadystatechange(data.data);
break;
case "ontimeout":
details.ontimeout?.();
break;
case "onerror":
details.onerror?.("");
break;
case "onabort":
details.onabort?.();
break;
case "onstream":
controller?.enqueue(new Uint8Array(data.data));
break;
default:
LoggerCore.logger().warn("GM_xmlhttpRequest resp is error", {
data,
});
break;
}
});
});
};
// 由于需要同步返回一个abort但是一些操作是异步的所以需要在这里处理
handler();
return {
abort: () => {
if (connect) {
connect.disconnect();
}
},
};
}
@GMContext.API()
GM_download(url: GMTypes.DownloadDetails | string, filename?: string): GMTypes.AbortHandle<void> {
let details: GMTypes.DownloadDetails;
if (typeof url === "string") {
details = {
name: filename || "",
url,
};
} else {
details = url;
}
let connect: MessageConnect;
this.connect("GM_download", [
{
method: details.method,
url: details.url,
name: details.name,
headers: details.headers,
saveAs: details.saveAs,
timeout: details.timeout,
cookie: details.cookie,
anonymous: details.anonymous,
},
]).then((con) => {
connect = con;
connect.onMessage((data: { action: string; data: any }) => {
switch (data.action) {
case "onload":
details.onload && details.onload(data.data);
break;
case "onprogress":
details.onprogress && details.onprogress(<GMTypes.XHRProgress>data.data);
break;
case "ontimeout":
details.ontimeout && details.ontimeout();
break;
case "onerror":
details.onerror &&
details.onerror({
error: "unknown",
});
break;
default:
LoggerCore.logger().warn("GM_download resp is error", {
data,
});
break;
}
});
});
return {
abort: () => {
connect?.disconnect();
},
};
}
@GMContext.API({
depend: ["GM_closeNotification", "GM_updateNotification"],
})
public async GM_notification(
detail: GMTypes.NotificationDetails | string,
ondone?: GMTypes.NotificationOnDone | string,
image?: string,
onclick?: GMTypes.NotificationOnClick
) {
this.eventId += 1;
let data: GMTypes.NotificationDetails;
if (typeof detail === "string") {
data = {};
data.text = detail;
switch (arguments.length) {
case 4:
data.onclick = onclick;
case 3:
data.image = image;
case 2:
data.title = <string>ondone;
default:
break;
}
} else {
data = Object.assign({}, detail);
data.ondone = data.ondone || <GMTypes.NotificationOnDone>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;
}
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":
case "buttonClick": {
click && click.apply({ id }, [id, resp.params.index]);
break;
}
case "close": {
done && done.apply({ id }, [resp.params.byUser]);
this.EE.removeAllListeners("GM_notification:" + this.eventId);
break;
}
default:
LoggerCore.logger().warn("GM_notification resp is error", {
resp,
});
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({ depend: ["GM_closeInTab"] })
public GM_openInTab(url: string, options?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab {
let option: GMTypes.OpenTabOptions = {};
if (arguments.length === 1) {
option.active = true;
} else if (typeof options === "boolean") {
option.active = !options;
} else {
option = <GMTypes.OpenTabOptions>options;
}
if (option.active === undefined) {
option.active = true;
}
let tabid: any;
const ret: GMTypes.Tab = {
close: () => {
tabid && this.GM_closeInTab(tabid);
},
};
this.sendMessage("GM_openInTab", [url, option]).then((id) => {
if (id) {
tabid = id;
this.EE.addListener("GM_openInTab:" + id, (resp: any) => {
switch (resp.event) {
case "oncreate":
tabid = resp.tabId;
break;
case "onclose":
ret.onclose && ret.onclose();
ret.closed = true;
this.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;
}
});
return ret;
}
@GMContext.API()
public GM_closeInTab(tabid: string) {
return this.sendMessage("GM_closeInTab", [tabid]);
}
@GMContext.API()
GM_getTab(callback: (data: any) => void) {
this.sendMessage("GM_getTab", []).then((data) => {
callback(data);
});
}
@GMContext.API()
GM_saveTab(obj: object) {
if (typeof obj === "object") {
obj = JSON.parse(JSON.stringify(obj));
}
this.sendMessage("GM_saveTab", [obj]);
}
@GMContext.API()
GM_getTabs(callback: (objs: { [key: string | number]: object }) => any) {
this.sendMessage("GM_getTabs", []).then((resp) => {
callback(resp);
});
}
@GMContext.API()
GM_setClipboard(data: string, info?: string | { type?: string; minetype?: string }) {
this.sendMessage("GM_setClipboard", [data, info]);
}
@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;
}
}