处理后台脚本API
This commit is contained in:
62
src/app/service/content/content.ts
Normal file
62
src/app/service/content/content.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { Client, sendMessage } from "@Packages/message/client";
|
||||
import { forwardMessage, Message, MessageSend, Server } from "@Packages/message/server";
|
||||
|
||||
// content页的处理
|
||||
export default class ContentRuntime {
|
||||
constructor(
|
||||
private extServer: Server,
|
||||
private server: Server,
|
||||
private extSend: MessageSend,
|
||||
private msg: Message
|
||||
) {}
|
||||
|
||||
start(scripts: ScriptRunResouce[]) {
|
||||
this.extServer.on("runtime/emitEvent", (data) => {
|
||||
// 转发给inject
|
||||
return sendMessage(this.msg, "inject/runtime/emitEvent", data);
|
||||
});
|
||||
this.extServer.on("runtime/valueUpdate", (data) => {
|
||||
// 转发给inject
|
||||
return sendMessage(this.msg, "inject/runtime/valueUpdate", data);
|
||||
});
|
||||
forwardMessage(
|
||||
"serviceWorker",
|
||||
"runtime/gmApi",
|
||||
this.server,
|
||||
this.extSend,
|
||||
(data: { api: string; params: any }) => {
|
||||
// 拦截关注的api
|
||||
switch (data.api) {
|
||||
case "CAT_createBlobUrl": {
|
||||
const file = data.params[0] as File;
|
||||
const url = URL.createObjectURL(file);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60 * 1000);
|
||||
return Promise.resolve(url);
|
||||
}
|
||||
case "CAT_fetchBlob": {
|
||||
return fetch(data.params[0]).then((res) => res.blob());
|
||||
}
|
||||
case "CAT_fetchDocument": {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.responseType = "document";
|
||||
xhr.open("GET", data.params[0]);
|
||||
xhr.onload = () => {
|
||||
resolve({
|
||||
relatedTarget: xhr.response,
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
);
|
||||
const client = new Client(this.msg, "inject");
|
||||
client.do("pageLoad", { scripts });
|
||||
}
|
||||
}
|
121
src/app/service/content/exec_script.test.ts
Normal file
121
src/app/service/content/exec_script.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import ExecScript from "./exec_script";
|
||||
import { compileScript, compileScriptCode } from "./utils";
|
||||
import { ExtVersion } from "@App/app/const";
|
||||
import { initTestEnv } from "@Tests/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
initTestEnv();
|
||||
|
||||
const scriptRes = {
|
||||
id: 0,
|
||||
name: "test",
|
||||
metadata: {
|
||||
version: ["1.0.0"],
|
||||
},
|
||||
code: "console.log('test')",
|
||||
sourceCode: "sourceCode",
|
||||
value: {},
|
||||
grantMap: {
|
||||
none: true,
|
||||
},
|
||||
} as unknown as ScriptRunResouce;
|
||||
|
||||
// @ts-ignore
|
||||
const noneExec = new ExecScript(scriptRes);
|
||||
|
||||
const scriptRes2 = {
|
||||
id: 0,
|
||||
name: "test",
|
||||
metadata: {
|
||||
version: ["1.0.0"],
|
||||
},
|
||||
code: "console.log('test')",
|
||||
sourceCode: "sourceCode",
|
||||
value: {},
|
||||
grantMap: {},
|
||||
} as unknown as ScriptRunResouce;
|
||||
|
||||
// @ts-ignore
|
||||
const sandboxExec = new ExecScript(scriptRes2);
|
||||
|
||||
describe("GM_info", () => {
|
||||
it("none", async () => {
|
||||
scriptRes.code = "return GM_info";
|
||||
noneExec.scriptFunc = compileScript(compileScriptCode(scriptRes));
|
||||
const ret = await noneExec.exec();
|
||||
expect(ret.version).toEqual(ExtVersion);
|
||||
expect(ret.script.version).toEqual("1.0.0");
|
||||
});
|
||||
it("sandbox", async () => {
|
||||
scriptRes2.code = "return GM_info";
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret = await sandboxExec.exec();
|
||||
expect(ret.version).toEqual(ExtVersion);
|
||||
expect(ret.script.version).toEqual("1.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsafeWindow", () => {
|
||||
it("sandbox", async () => {
|
||||
// @ts-ignore
|
||||
global.testUnsafeWindow = "ok";
|
||||
scriptRes2.code = "return unsafeWindow.testUnsafeWindow";
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual("ok");
|
||||
scriptRes2.code = "return window.testUnsafeWindow";
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret2 = await sandboxExec.exec();
|
||||
expect(ret2).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sandbox", () => {
|
||||
it("global", async () => {
|
||||
scriptRes2.code = "window.testObj = 'ok';return window.testObj";
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
let ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual("ok");
|
||||
scriptRes2.code = "window.testObj = 'ok2';return testObj";
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual("ok2");
|
||||
});
|
||||
it("this", async () => {
|
||||
scriptRes2.code = "this.testObj='ok2';return testObj;";
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual("ok2");
|
||||
});
|
||||
it("this2", async () => {
|
||||
scriptRes2.code = `
|
||||
!function(t, e) {
|
||||
"object" == typeof exports ? module.exports = exports = e() : "function" == typeof define && define.amd ? define([], e) : t.CryptoJS = e()
|
||||
console.log("object" == typeof exports,"function" == typeof define)
|
||||
} (this, function () {
|
||||
return { test: "ok3" }
|
||||
});
|
||||
console.log(CryptoJS)
|
||||
return CryptoJS.test;`;
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual("ok3");
|
||||
});
|
||||
|
||||
// 沉浸式翻译, 常量值被改变
|
||||
it("NodeFilter #214", async () => {
|
||||
scriptRes2.code = `return NodeFilter.FILTER_REJECT;`;
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual(2);
|
||||
});
|
||||
|
||||
// RegExp.$x 内容被覆盖 https://github.com/scriptscat/scriptcat/issues/293
|
||||
it("RegExp", async () => {
|
||||
scriptRes2.code = `let ok = /12(3)/.test('123');return RegExp.$1;`;
|
||||
sandboxExec.scriptFunc = compileScript(compileScriptCode(scriptRes2));
|
||||
const ret = await sandboxExec.exec();
|
||||
expect(ret).toEqual("3");
|
||||
});
|
||||
});
|
93
src/app/service/content/exec_script.ts
Normal file
93
src/app/service/content/exec_script.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
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";
|
||||
|
||||
export type ValueUpdateSender = {
|
||||
runFlag: string;
|
||||
tabId?: number;
|
||||
};
|
||||
|
||||
export type ValueUpdateData = {
|
||||
oldValue: any;
|
||||
value: any;
|
||||
key: string; // 值key
|
||||
uuid: string;
|
||||
storageName: string; // 储存name
|
||||
sender: ValueUpdateSender;
|
||||
};
|
||||
|
||||
export class RuntimeMessage {}
|
||||
|
||||
// 执行脚本,控制脚本执行与停止
|
||||
export default class ExecScript {
|
||||
scriptRes: ScriptRunResouce;
|
||||
|
||||
scriptFunc: ScriptFunc;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
proxyContent: any;
|
||||
|
||||
sandboxContent?: GMApi;
|
||||
|
||||
GM_info: any;
|
||||
|
||||
constructor(
|
||||
scriptRes: ScriptRunResouce,
|
||||
envPrefix: "content" | "offscreen",
|
||||
message: Message,
|
||||
code: string | ScriptFunc,
|
||||
thisContext?: { [key: string]: any }
|
||||
) {
|
||||
this.scriptRes = scriptRes;
|
||||
this.logger = LoggerCore.getInstance().logger({
|
||||
component: "exec",
|
||||
script: this.scriptRes.uuid,
|
||||
name: this.scriptRes.name,
|
||||
});
|
||||
this.GM_info = GMApi.GM_info(this.scriptRes);
|
||||
// 构建脚本资源
|
||||
if (typeof code === "string") {
|
||||
this.scriptFunc = compileScript(code);
|
||||
} else {
|
||||
this.scriptFunc = code;
|
||||
}
|
||||
const grantMap: { [key: string]: boolean } = {};
|
||||
scriptRes.metadata.grant?.forEach((key) => {
|
||||
grantMap[key] = true;
|
||||
});
|
||||
if (grantMap.none) {
|
||||
// 不注入任何GM api
|
||||
this.proxyContent = global;
|
||||
} else {
|
||||
// 构建脚本GM上下文
|
||||
this.sandboxContent = createContext(scriptRes, this.GM_info, envPrefix, message);
|
||||
this.proxyContent = proxyContext(global, this.sandboxContent, thisContext);
|
||||
}
|
||||
}
|
||||
|
||||
emitEvent(event: string, data: any) {
|
||||
switch (event) {
|
||||
case "menuClick":
|
||||
this.sandboxContent?.menuClick(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
valueUpdate(data: ValueUpdateData) {
|
||||
this.sandboxContent?.valueUpdate(data);
|
||||
}
|
||||
|
||||
exec() {
|
||||
this.logger.debug("script start");
|
||||
return this.scriptFunc.apply(this.proxyContent, [this.proxyContent, this.GM_info]);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.logger.debug("script stop");
|
||||
return true;
|
||||
}
|
||||
}
|
82
src/app/service/content/exec_warp.ts
Normal file
82
src/app/service/content/exec_warp.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import ExecScript from "./exec_script";
|
||||
import { Message } from "@Packages/message/server";
|
||||
|
||||
export class CATRetryError {
|
||||
msg: string;
|
||||
|
||||
time: Date;
|
||||
|
||||
constructor(msg: string, time: number | Date) {
|
||||
this.msg = msg;
|
||||
if (typeof time === "number") {
|
||||
this.time = new Date(Date.now() + time * 1000);
|
||||
} else {
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BgExecScriptWarp extends ExecScript {
|
||||
setTimeout: Map<number, boolean>;
|
||||
|
||||
setInterval: Map<number, boolean>;
|
||||
|
||||
constructor(scriptRes: ScriptRunResouce, message: Message) {
|
||||
const thisContext: { [key: string]: any } = {};
|
||||
const setTimeout = new Map<number, any>();
|
||||
const setInterval = new Map<number, any>();
|
||||
thisContext.setTimeout = function (handler: () => void, timeout: number | undefined, ...args: any) {
|
||||
const t = global.setTimeout(
|
||||
function () {
|
||||
setTimeout.delete(t);
|
||||
if (typeof handler === "function") {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
...args
|
||||
);
|
||||
setTimeout.set(t, true);
|
||||
return t;
|
||||
};
|
||||
thisContext.clearTimeout = function (t: number) {
|
||||
setTimeout.delete(t);
|
||||
global.clearTimeout(t);
|
||||
};
|
||||
thisContext.setInterval = function (handler: () => void, timeout: number | undefined, ...args: any) {
|
||||
const t = global.setInterval(
|
||||
function () {
|
||||
if (typeof handler === "function") {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
...args
|
||||
);
|
||||
setInterval.set(t, true);
|
||||
return t;
|
||||
};
|
||||
thisContext.clearInterval = function (t: number) {
|
||||
setInterval.delete(t);
|
||||
global.clearInterval(t);
|
||||
};
|
||||
// @ts-ignore
|
||||
thisContext.CATRetryError = CATRetryError;
|
||||
super(scriptRes, "offscreen", message, scriptRes.code, thisContext);
|
||||
this.setTimeout = setTimeout;
|
||||
this.setInterval = setInterval;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.setTimeout.forEach((_, t) => {
|
||||
global.clearTimeout(t);
|
||||
});
|
||||
this.setTimeout.clear();
|
||||
this.setInterval.forEach((_, t) => {
|
||||
global.clearInterval(t);
|
||||
});
|
||||
this.setInterval.clear();
|
||||
return super.stop();
|
||||
}
|
||||
}
|
515
src/app/service/content/gm_api.ts
Normal file
515
src/app/service/content/gm_api.ts
Normal file
@ -0,0 +1,515 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { 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[];
|
||||
listener?: () => void;
|
||||
}
|
||||
|
||||
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 (param.listener) {
|
||||
param.listener();
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
menuClick(id: number) {
|
||||
this.EE.emit("menuClick" + id);
|
||||
}
|
||||
|
||||
// 获取脚本信息和管理器信息
|
||||
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);
|
||||
}
|
||||
|
||||
@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_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.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", {
|
||||
action: data.action,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
// 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理
|
||||
handler();
|
||||
return {
|
||||
abort: () => {
|
||||
if (connect) {
|
||||
connect.disconnect();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@GMContext.API()
|
||||
public async GM_notification(
|
||||
detail: GMTypes.NotificationDetails | string,
|
||||
ondone?: GMTypes.NotificationOnDone | string,
|
||||
image?: string,
|
||||
onclick?: GMTypes.NotificationOnClick
|
||||
) {
|
||||
let data: GMTypes.NotificationDetails = {};
|
||||
if (typeof detail === "string") {
|
||||
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 = 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.eventId += 1;
|
||||
this.sendMessage("GM_notification", [data]);
|
||||
this.EE.addListener("GM_notification:" + this.eventId, (resp: any) => {
|
||||
switch (resp.event) {
|
||||
case "click": {
|
||||
click && click.apply({ id: resp.id }, [resp.id, resp.index]);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
done && done.apply({ id: resp.id }, [resp.user]);
|
||||
break;
|
||||
}
|
||||
case "create": {
|
||||
create && create.apply({ id: resp.id }, [resp.id]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
LoggerCore.logger().warn("GM_notification resp is error", {
|
||||
resp,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
65
src/app/service/content/inject.ts
Normal file
65
src/app/service/content/inject.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { Message, Server } from "@Packages/message/server";
|
||||
import ExecScript, { ValueUpdateData } from "./exec_script";
|
||||
import { addStyle, ScriptFunc } from "./utils";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { EmitEventRequest } from "../service_worker/runtime";
|
||||
|
||||
export class InjectRuntime {
|
||||
execList: ExecScript[] = [];
|
||||
|
||||
constructor(
|
||||
private server: Server,
|
||||
private msg: Message,
|
||||
private scripts: ScriptRunResouce[]
|
||||
) {}
|
||||
|
||||
start() {
|
||||
this.scripts.forEach((script) => {
|
||||
// @ts-ignore
|
||||
const scriptFunc = window[script.flag];
|
||||
if (scriptFunc) {
|
||||
this.execScript(script, scriptFunc);
|
||||
} else {
|
||||
// 监听脚本加载,和屏蔽读取
|
||||
Object.defineProperty(window, script.flag, {
|
||||
configurable: true,
|
||||
set: (val: ScriptFunc) => {
|
||||
this.execScript(script, val);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
this.server.on("runtime/emitEvent", (data: EmitEventRequest) => {
|
||||
// 转发给脚本
|
||||
const exec = this.execList.find((val) => val.scriptRes.uuid === data.uuid);
|
||||
if (exec) {
|
||||
exec.emitEvent(data.event, data.data);
|
||||
}
|
||||
});
|
||||
this.server.on("runtime/valueUpdate", (data: ValueUpdateData) => {
|
||||
this.execList
|
||||
.filter((val) => val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName)
|
||||
.forEach((val) => {
|
||||
val.valueUpdate(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
execScript(script: ScriptRunResouce, scriptFunc: ScriptFunc) {
|
||||
// @ts-ignore
|
||||
delete window[script.flag];
|
||||
const exec = new ExecScript(script, "content", this.msg, scriptFunc);
|
||||
this.execList.push(exec);
|
||||
// 注入css
|
||||
if (script.metadata["require-css"]) {
|
||||
script.metadata["require-css"].forEach((val) => {
|
||||
const res = script.resource[val];
|
||||
if (res) {
|
||||
addStyle(res.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
exec.exec();
|
||||
}
|
||||
}
|
102
src/app/service/content/utils.test.ts
Normal file
102
src/app/service/content/utils.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { init, proxyContext, writables } from "./utils";
|
||||
|
||||
describe("proxy context", () => {
|
||||
const context: any = {};
|
||||
const global: any = {
|
||||
gbok: "gbok",
|
||||
onload: null,
|
||||
eval: () => {
|
||||
console.log("eval");
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
location: "ok",
|
||||
};
|
||||
init.set("onload", true);
|
||||
init.set("location", true);
|
||||
const _this = proxyContext(global, context);
|
||||
|
||||
it("set contenxt", () => {
|
||||
_this["md5"] = "ok";
|
||||
expect(_this["md5"]).toEqual("ok");
|
||||
expect(global["md5"]).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("set window null", () => {
|
||||
_this["onload"] = "ok";
|
||||
expect(_this["onload"]).toEqual("ok");
|
||||
expect(global["onload"]).toEqual(null);
|
||||
_this["onload"] = undefined;
|
||||
expect(_this["onload"]).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("update", () => {
|
||||
_this["okk"] = "ok";
|
||||
expect(_this["okk"]).toEqual("ok");
|
||||
expect(global["okk"]).toEqual(undefined);
|
||||
_this["okk"] = "ok2";
|
||||
expect(_this["okk"]).toEqual("ok2");
|
||||
expect(global["okk"]).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("禁止穿透global对象", () => {
|
||||
expect(_this["gbok"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("禁止修改window", () => {
|
||||
expect(() => (_this["window"] = "ok")).toThrow();
|
||||
});
|
||||
|
||||
it("访问location", () => {
|
||||
expect(_this.location).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// 只允许访问onxxxxx
|
||||
describe("window", () => {
|
||||
const _this = proxyContext({ onanimationstart: null }, {});
|
||||
it("window", () => {
|
||||
expect(_this.onanimationstart).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("兼容问题", () => {
|
||||
const _this = proxyContext({}, {});
|
||||
// https://github.com/xcanwin/KeepChatGPT 环境隔离得不够干净导致的
|
||||
it("Uncaught TypeError: Illegal invocation #189", () => {
|
||||
return new Promise((resolve) => {
|
||||
console.log(_this.setTimeout.prototype);
|
||||
_this.setTimeout(resolve, 100);
|
||||
});
|
||||
});
|
||||
// AC-baidu-重定向优化百度搜狗谷歌必应搜索_favicon_双列
|
||||
it("TypeError: Object.freeze is not a function #116", () => {
|
||||
expect(() => _this.Object.freeze({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Symbol", () => {
|
||||
const _this = proxyContext({}, {});
|
||||
// 允许往global写入Symbol属性,影响内容: https://bbs.tampermonkey.net.cn/thread-5509-1-1.html
|
||||
it("Symbol", () => {
|
||||
const s = Symbol("test");
|
||||
_this[s] = "ok";
|
||||
expect(_this[s]).toEqual("ok");
|
||||
});
|
||||
// toString.call(window)返回的是'[object Object]'而不是'[object Window]',影响内容: https://github.com/scriptscat/scriptcat/issues/260
|
||||
it("Window", () => {
|
||||
expect(toString.call(_this)).toEqual("[object Window]");
|
||||
});
|
||||
});
|
||||
|
||||
// Object.hasOwnProperty穿透 https://github.com/scriptscat/scriptcat/issues/272
|
||||
describe("Object", () => {
|
||||
const _this = proxyContext({}, {});
|
||||
it("hasOwnProperty", () => {
|
||||
expect(_this.hasOwnProperty("test1")).toEqual(false);
|
||||
_this.test1 = "ok";
|
||||
expect(_this.hasOwnProperty("test1")).toEqual(true);
|
||||
expect(_this.hasOwnProperty("test")).toEqual(true);
|
||||
});
|
||||
});
|
355
src/app/service/content/utils.ts
Normal file
355
src/app/service/content/utils.ts
Normal file
@ -0,0 +1,355 @@
|
||||
import { ScriptRunResouce } from "@App/app/repo/scripts";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import GMApi, { ApiValue, GMContext } from "./gm_api";
|
||||
import { has } from "@App/pkg/utils/lodash";
|
||||
import { Message } from "@Packages/message/server";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
// 构建脚本运行代码
|
||||
export function compileScriptCode(scriptRes: ScriptRunResouce): string {
|
||||
let require = "";
|
||||
if (scriptRes.metadata.require) {
|
||||
scriptRes.metadata.require.forEach((val) => {
|
||||
const res = scriptRes.resource[val];
|
||||
if (res) {
|
||||
require = `${require}\n${res.content}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
const code = require + scriptRes.code;
|
||||
return `with (context) return (async ()=>{\n${code}\n//# sourceURL=${chrome.runtime.getURL(
|
||||
`/${encodeURI(scriptRes.name)}.user.js`
|
||||
)}\n})()`;
|
||||
}
|
||||
|
||||
export type ScriptFunc = (context: any, GM_info: any) => any;
|
||||
|
||||
// 通过脚本代码编译脚本函数
|
||||
export function compileScript(code: string): ScriptFunc {
|
||||
return <ScriptFunc>new Function("context", "GM_info", code);
|
||||
}
|
||||
|
||||
export function compileInjectScript(script: ScriptRunResouce): string {
|
||||
return `window['${script.flag}']=function(context,GM_info){\n${script.code}\n}`;
|
||||
}
|
||||
|
||||
// 设置api依赖
|
||||
function setDepend(context: { [key: string]: any }, apiVal: ApiValue) {
|
||||
if (apiVal.param.depend) {
|
||||
for (let i = 0; i < apiVal.param.depend.length; i += 1) {
|
||||
const value = apiVal.param.depend[i];
|
||||
const dependApi = GMContext.apis.get(value);
|
||||
if (!dependApi) {
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("GM.")) {
|
||||
const [, t] = value.split(".");
|
||||
(<{ [key: string]: any }>context.GM)[t] = dependApi.api.bind(context);
|
||||
} else {
|
||||
context[value] = dependApi.api.bind(context);
|
||||
}
|
||||
setDepend(context, dependApi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建沙盒上下文
|
||||
export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, envPrefix: string, message: Message): GMApi {
|
||||
// 按照GMApi构建
|
||||
const context: { [key: string]: any } = {
|
||||
prefix: envPrefix,
|
||||
message: message,
|
||||
scriptRes,
|
||||
valueChangeListener: new Map<number, { name: string; listener: GMTypes.ValueChangeListener }>(),
|
||||
sendMessage: GMApi.prototype.sendMessage,
|
||||
connect: GMApi.prototype.connect,
|
||||
runFlag: uuidv4(),
|
||||
eventId: 10000,
|
||||
valueUpdate: GMApi.prototype.valueUpdate,
|
||||
menuClick: GMApi.prototype.menuClick,
|
||||
EE: new EventEmitter(),
|
||||
GM: { Info: GMInfo },
|
||||
GM_info: GMInfo,
|
||||
};
|
||||
if (scriptRes.metadata.grant) {
|
||||
scriptRes.metadata.grant.forEach((val) => {
|
||||
const api = GMContext.apis.get(val);
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
if (val.startsWith("GM.")) {
|
||||
const [, t] = val.split(".");
|
||||
(<{ [key: string]: any }>context.GM)[t] = api.api.bind(context);
|
||||
} else if (val === "GM_cookie") {
|
||||
// 特殊处理GM_cookie.list之类
|
||||
context[val] = api.api.bind(context);
|
||||
|
||||
const GM_cookie = function (action: string) {
|
||||
return (
|
||||
details: GMTypes.CookieDetails,
|
||||
done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void
|
||||
) => {
|
||||
return context[val](action, details, done);
|
||||
};
|
||||
};
|
||||
context[val].list = GM_cookie("list");
|
||||
context[val].delete = GM_cookie("delete");
|
||||
context[val].set = GM_cookie("set");
|
||||
} else {
|
||||
context[val] = api.api.bind(context);
|
||||
}
|
||||
setDepend(context, api);
|
||||
});
|
||||
}
|
||||
context.unsafeWindow = window;
|
||||
return <GMApi>context;
|
||||
}
|
||||
|
||||
export const writables: { [key: string]: any } = {
|
||||
addEventListener: global.addEventListener.bind(global),
|
||||
removeEventListener: global.removeEventListener.bind(global),
|
||||
dispatchEvent: global.dispatchEvent.bind(global),
|
||||
};
|
||||
|
||||
// 记录初始的window字段
|
||||
export const init = new Map<string, boolean>();
|
||||
|
||||
// 需要用到全局的
|
||||
export const unscopables: { [key: string]: boolean } = {
|
||||
NodeFilter: true,
|
||||
RegExp: true,
|
||||
};
|
||||
|
||||
// 复制原有的,防止被前端网页复写
|
||||
const descs = Object.getOwnPropertyDescriptors(global);
|
||||
Object.keys(descs).forEach((key) => {
|
||||
const desc = descs[key];
|
||||
// 可写但不在特殊配置writables中
|
||||
if (desc && desc.writable && !writables[key]) {
|
||||
if (typeof desc.value === "function") {
|
||||
// 判断是否需要bind,例如Object、Function这些就不需要bind
|
||||
if (desc.value.prototype) {
|
||||
writables[key] = desc.value;
|
||||
} else {
|
||||
writables[key] = desc.value.bind(global);
|
||||
}
|
||||
} else {
|
||||
writables[key] = desc.value;
|
||||
}
|
||||
} else {
|
||||
init.set(key, true);
|
||||
}
|
||||
});
|
||||
|
||||
export function warpObject(thisContext: object, ...context: object[]) {
|
||||
// 处理Object上的方法
|
||||
thisContext.hasOwnProperty = (name: PropertyKey) => {
|
||||
return (
|
||||
Object.hasOwnProperty.call(thisContext, name) || context.some((val) => Object.hasOwnProperty.call(val, name))
|
||||
);
|
||||
};
|
||||
thisContext.isPrototypeOf = (name: object) => {
|
||||
return Object.isPrototypeOf.call(thisContext, name) || context.some((val) => Object.isPrototypeOf.call(val, name));
|
||||
};
|
||||
thisContext.propertyIsEnumerable = (name: PropertyKey) => {
|
||||
return (
|
||||
Object.propertyIsEnumerable.call(thisContext, name) ||
|
||||
context.some((val) => Object.propertyIsEnumerable.call(val, name))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 拦截上下文
|
||||
export function proxyContext(global: any, context: any, thisContext?: { [key: string]: any }) {
|
||||
const special = Object.assign(writables);
|
||||
// 处理某些特殊的属性
|
||||
// 后台脚本要不要考虑不能使用eval?
|
||||
if (!thisContext) {
|
||||
thisContext = {};
|
||||
}
|
||||
thisContext.eval = global.eval;
|
||||
thisContext.define = undefined;
|
||||
warpObject(thisContext, special, global, context);
|
||||
// keyword是与createContext时同步的,避免访问到context的内部变量
|
||||
const contextKeyword: { [key: string]: any } = {
|
||||
message: 1,
|
||||
valueChangeListener: 1,
|
||||
connect: 1,
|
||||
runFlag: 1,
|
||||
valueUpdate: 1,
|
||||
sendMessage: 1,
|
||||
scriptRes: 1,
|
||||
};
|
||||
// @ts-ignore
|
||||
const proxy = new Proxy(context, {
|
||||
defineProperty(_, name, desc) {
|
||||
if (Object.defineProperty(thisContext, name, desc)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
get(_, name): any {
|
||||
switch (name) {
|
||||
case "window":
|
||||
case "self":
|
||||
case "globalThis":
|
||||
return proxy;
|
||||
case "top":
|
||||
case "parent":
|
||||
if (global[name] === global.self) {
|
||||
return special.global || proxy;
|
||||
}
|
||||
return global.top;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (name !== "undefined") {
|
||||
if (has(thisContext, name)) {
|
||||
// @ts-ignore
|
||||
return thisContext[name];
|
||||
}
|
||||
if (typeof name === "string") {
|
||||
if (has(context, name)) {
|
||||
if (has(contextKeyword, name)) {
|
||||
return undefined;
|
||||
}
|
||||
return context[name];
|
||||
}
|
||||
if (has(special, name)) {
|
||||
if (typeof special[name] === "function" && !(<{ prototype: any }>special[name]).prototype) {
|
||||
return (<{ bind: any }>special[name]).bind(global);
|
||||
}
|
||||
return special[name];
|
||||
}
|
||||
if (has(global, name)) {
|
||||
// 特殊处理onxxxx的事件
|
||||
if (name.startsWith("on")) {
|
||||
if (typeof global[name] === "function" && !(<{ prototype: any }>global[name]).prototype) {
|
||||
return (<{ bind: any }>global[name]).bind(global);
|
||||
}
|
||||
return global[name];
|
||||
}
|
||||
}
|
||||
if (init.has(name)) {
|
||||
const val = global[name];
|
||||
if (typeof val === "function" && !(<{ prototype: any }>val).prototype) {
|
||||
return (<{ bind: any }>val).bind(global);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
} else if (name === Symbol.unscopables) {
|
||||
return unscopables;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
has(_, name) {
|
||||
switch (name) {
|
||||
case "window":
|
||||
case "self":
|
||||
case "globalThis":
|
||||
return true;
|
||||
case "top":
|
||||
case "parent":
|
||||
if (global[name] === global.self) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (name !== "undefined") {
|
||||
if (typeof name === "string") {
|
||||
if (has(unscopables, name)) {
|
||||
return false;
|
||||
}
|
||||
if (has(thisContext, name)) {
|
||||
return true;
|
||||
}
|
||||
if (has(context, name)) {
|
||||
if (has(contextKeyword, name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (has(special, name)) {
|
||||
return true;
|
||||
}
|
||||
// 只处理onxxxx的事件
|
||||
if (has(global[name], name)) {
|
||||
if (name.startsWith("on")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (typeof name === "symbol") {
|
||||
return has(thisContext, name);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
set(_, name: string, val) {
|
||||
switch (name) {
|
||||
case "window":
|
||||
case "self":
|
||||
case "globalThis":
|
||||
return false;
|
||||
default:
|
||||
}
|
||||
if (has(special, name)) {
|
||||
special[name] = val;
|
||||
return true;
|
||||
}
|
||||
if (init.has(name)) {
|
||||
const des = Object.getOwnPropertyDescriptor(global, name);
|
||||
// 只读的return
|
||||
if (des && des.get && !des.set && des.configurable) {
|
||||
return true;
|
||||
}
|
||||
// 只处理onxxxx的事件
|
||||
if (has(global, name) && name.startsWith("on")) {
|
||||
if (val === undefined) {
|
||||
global.removeEventListener(name.slice(2), thisContext[name]);
|
||||
} else {
|
||||
if (thisContext[name]) {
|
||||
global.removeEventListener(name.slice(2), thisContext[name]);
|
||||
}
|
||||
global.addEventListener(name.slice(2), val);
|
||||
}
|
||||
thisContext[name] = val;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
thisContext[name] = val;
|
||||
return true;
|
||||
},
|
||||
getOwnPropertyDescriptor(_, name) {
|
||||
try {
|
||||
let ret = Object.getOwnPropertyDescriptor(thisContext, name);
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
ret = Object.getOwnPropertyDescriptor(context, name);
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
ret = Object.getOwnPropertyDescriptor(global, name);
|
||||
return ret;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
proxy[Symbol.toStringTag] = "Window";
|
||||
return proxy;
|
||||
}
|
||||
|
||||
export function addStyle(css: string): HTMLElement {
|
||||
const dom = document.createElement("style");
|
||||
dom.innerHTML = css;
|
||||
if (document.head) {
|
||||
return document.head.appendChild(dom);
|
||||
}
|
||||
return document.documentElement.appendChild(dom);
|
||||
}
|
16
src/app/service/gm_api.test.ts
Normal file
16
src/app/service/gm_api.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { initTestEnv } from "@Tests/utils";
|
||||
|
||||
initTestEnv();
|
||||
// serviceWorker环境
|
||||
|
||||
beforeAll(() => {});
|
||||
|
||||
describe("GM xhr", () => {
|
||||
beforeEach(() => {
|
||||
// See https://webext-core.aklinker1.io/fake-browser/reseting-state
|
||||
});
|
||||
it("123123", async () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
@ -48,7 +48,9 @@ export class OffscreenManager {
|
||||
script.init();
|
||||
// 转发从sandbox来的gm api请求
|
||||
forwardMessage("serviceWorker", "runtime/gmApi", this.windowServer, this.extensionMessage);
|
||||
// 转发message queue请求
|
||||
// 转发valueUpdate与emitEvent
|
||||
forwardMessage("sandbox", "runtime/valueUpdate", this.windowServer, this.windowMessage);
|
||||
forwardMessage("sandbox", "runtime/emitEvent", this.windowServer, this.windowMessage);
|
||||
|
||||
const gmApi = new GMApi(this.windowServer.group("gmApi"));
|
||||
gmApi.init();
|
||||
|
@ -7,12 +7,14 @@ import {
|
||||
SCRIPT_TYPE_BACKGROUND,
|
||||
ScriptRunResouce,
|
||||
} from "@App/app/repo/scripts";
|
||||
import ExecScript from "@App/runtime/content/exec_script";
|
||||
import { BgExecScriptWarp, CATRetryError } from "@App/runtime/content/exec_warp";
|
||||
import { Server } from "@Packages/message/server";
|
||||
import { WindowMessage } from "@Packages/message/window_message";
|
||||
import { CronJob } from "cron";
|
||||
import { proxyUpdateRunStatus } from "../offscreen/client";
|
||||
import { BgExecScriptWarp } from "../content/exec_warp";
|
||||
import ExecScript, { ValueUpdateData } from "../content/exec_script";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { EmitEventRequest } from "../service_worker/runtime";
|
||||
|
||||
export class Runtime {
|
||||
cronJob: Map<string, Array<CronJob>> = new Map();
|
||||
@ -290,10 +292,30 @@ export class Runtime {
|
||||
return this.execScript(script, true);
|
||||
}
|
||||
|
||||
valueUpdate(data: ValueUpdateData) {
|
||||
// 转发给脚本
|
||||
this.execScripts.forEach((val) => {
|
||||
if (val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName) {
|
||||
val.valueUpdate(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emitEvent(data: EmitEventRequest) {
|
||||
// 转发给脚本
|
||||
const exec = this.execScripts.get(data.uuid);
|
||||
if (exec) {
|
||||
exec.emitEvent(data.event, data.data);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.api.on("enableScript", this.enableScript.bind(this));
|
||||
this.api.on("disableScript", this.disableScript.bind(this));
|
||||
this.api.on("runScript", this.runScript.bind(this));
|
||||
this.api.on("stopScript", this.stopScript.bind(this));
|
||||
|
||||
this.api.on("runtime/valueUpdate", this.valueUpdate.bind(this));
|
||||
this.api.on("runtime/emitEvent", this.emitEvent.bind(this));
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import { ValueService } from "@App/app/service/service_worker/value";
|
||||
import PermissionVerify from "./permission_verify";
|
||||
import { connect } from "@Packages/message/client";
|
||||
import Cache, { incr } from "@App/app/cache";
|
||||
import { unsafeHeaders } from "@App/runtime/utils";
|
||||
import EventEmitter from "eventemitter3";
|
||||
import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { RuntimeService } from "./runtime";
|
||||
@ -24,6 +23,35 @@ export type Request = MessageRequest & {
|
||||
script: Script;
|
||||
};
|
||||
|
||||
export const unsafeHeaders: { [key: string]: boolean } = {
|
||||
// 部分浏览器中并未允许
|
||||
"user-agent": true,
|
||||
// 这两个是前缀
|
||||
"proxy-": true,
|
||||
"sec-": true,
|
||||
// cookie已经特殊处理
|
||||
cookie: true,
|
||||
"accept-charset": true,
|
||||
"accept-encoding": true,
|
||||
"access-control-request-headers": true,
|
||||
"access-control-request-method": true,
|
||||
connection: true,
|
||||
"content-length": true,
|
||||
date: true,
|
||||
dnt: true,
|
||||
expect: true,
|
||||
"feature-policy": true,
|
||||
host: true,
|
||||
"keep-alive": true,
|
||||
origin: true,
|
||||
referer: true,
|
||||
te: true,
|
||||
trailer: true,
|
||||
"transfer-encoding": true,
|
||||
upgrade: true,
|
||||
via: true,
|
||||
};
|
||||
|
||||
export type Api = (request: Request, con: GetSender) => Promise<any>;
|
||||
|
||||
export default class GMApi {
|
||||
@ -194,7 +222,7 @@ export default class GMApi {
|
||||
id: id,
|
||||
name: name,
|
||||
accessKey: accessKey,
|
||||
tabId: sender.getSender().tab!.id!,
|
||||
tabId: sender.getSender().tab?.id || -1,
|
||||
frameId: sender.getSender().frameId,
|
||||
documentId: sender.getSender().documentId,
|
||||
});
|
||||
@ -207,7 +235,7 @@ export default class GMApi {
|
||||
this.mq.emit("unregisterMenuCommand", {
|
||||
uuid: request.script.uuid,
|
||||
id: id,
|
||||
tabId: sender.getSender().tab!.id!,
|
||||
tabId: sender.getSender().tab?.id || -1,
|
||||
frameId: sender.getSender().frameId,
|
||||
});
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
subscribeScriptMenuRegister,
|
||||
subscribeScriptRunStatus,
|
||||
} from "../queue";
|
||||
import { getStorageName } from "@App/runtime/utils";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
|
||||
export type ScriptMenuItem = {
|
||||
id: number;
|
||||
@ -206,7 +206,7 @@ export class PopupService {
|
||||
|
||||
// 事务更新脚本菜单
|
||||
txUpdateScriptMenu(tabId: number, callback: (menu: ScriptMenu[]) => Promise<any>) {
|
||||
return Cache.getInstance().tx("tabScript:" + tabId, async (menu) => {
|
||||
return Cache.getInstance().tx<ScriptMenu[]>("tabScript:" + tabId, async (menu) => {
|
||||
return callback(menu || []);
|
||||
});
|
||||
}
|
||||
@ -252,14 +252,15 @@ export class PopupService {
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
return;
|
||||
}
|
||||
if (script.status !== SCRIPT_STATUS_ENABLE) {
|
||||
return;
|
||||
}
|
||||
return this.txUpdateScriptMenu(-1, async (menu) => {
|
||||
const scriptMenu = menu.find((item) => item.uuid === script.uuid);
|
||||
if (script.status === SCRIPT_STATUS_ENABLE) {
|
||||
// 加入菜单
|
||||
if (!scriptMenu) {
|
||||
const item = this.scriptToMenu(script);
|
||||
menu.push(item);
|
||||
}
|
||||
// 加入菜单
|
||||
if (!scriptMenu) {
|
||||
const item = this.scriptToMenu(script);
|
||||
menu.push(item);
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
@ -294,24 +295,22 @@ export class PopupService {
|
||||
const index = menu.findIndex((item) => item.uuid === uuid);
|
||||
if (index !== -1) {
|
||||
menu.splice(index, 1);
|
||||
return menu;
|
||||
}
|
||||
return null;
|
||||
return menu;
|
||||
});
|
||||
});
|
||||
subscribeScriptRunStatus(this.mq, async ({ uuid, runStatus }) => {
|
||||
return this.txUpdateScriptMenu(-1, async (menu) => {
|
||||
const scriptMenu = menu.find((item) => item.uuid === uuid);
|
||||
if (scriptMenu) {
|
||||
if (scriptMenu.runStatus === SCRIPT_RUN_STATUS_RUNNING) {
|
||||
scriptMenu.runStatus = runStatus;
|
||||
if (runStatus === SCRIPT_RUN_STATUS_RUNNING) {
|
||||
scriptMenu.runNum = 1;
|
||||
} else {
|
||||
scriptMenu.runNum = 0;
|
||||
}
|
||||
scriptMenu.runStatus = runStatus;
|
||||
return menu;
|
||||
}
|
||||
return null;
|
||||
return menu;
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -330,13 +329,12 @@ export class PopupService {
|
||||
documentId: string;
|
||||
}) {
|
||||
// 菜单点击事件
|
||||
this.runtime.sendMessageToTab(
|
||||
this.runtime.EmitEventToTab(
|
||||
tabId,
|
||||
"menuClick",
|
||||
{
|
||||
uuid,
|
||||
id,
|
||||
tabId,
|
||||
event: "menuClick",
|
||||
data: id,
|
||||
},
|
||||
{
|
||||
frameId,
|
||||
@ -372,14 +370,21 @@ export class PopupService {
|
||||
const [, , uuid, id] = menuIds;
|
||||
// 寻找menu信息
|
||||
const menu = await this.getScriptMenu(tab!.id!);
|
||||
const script = menu.find((item) => item.uuid === uuid);
|
||||
let script = menu.find((item) => item.uuid === uuid);
|
||||
let bgscript = false;
|
||||
if (!script) {
|
||||
// 从后台脚本中寻找
|
||||
const backgroundMenu = await this.getScriptMenu(-1);
|
||||
script = backgroundMenu.find((item) => item.uuid === uuid);
|
||||
bgscript = true;
|
||||
}
|
||||
if (script) {
|
||||
const menuItem = script.menus.find((item) => item.id === parseInt(id, 10));
|
||||
if (menuItem) {
|
||||
this.menuClick({
|
||||
uuid: script.uuid,
|
||||
id: menuItem.id,
|
||||
tabId: tab!.id!,
|
||||
tabId: bgscript ? -1 : tab!.id!,
|
||||
frameId: menuItem.frameId || 0,
|
||||
documentId: menuItem.documentId || "",
|
||||
});
|
||||
|
@ -16,11 +16,11 @@ import { ScriptService } from "./script";
|
||||
import { runScript, stopScript } from "../offscreen/client";
|
||||
import { getRunAt } from "./utils";
|
||||
import { randomString } from "@App/pkg/utils/utils";
|
||||
import { compileInjectScript } from "@App/runtime/content/utils";
|
||||
import Cache from "@App/app/cache";
|
||||
import { dealPatternMatches, UrlMatch } from "@App/pkg/utils/match";
|
||||
import { ExtensionContentMessageSend } from "@Packages/message/extension_message";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
import { compileInjectScript } from "../content/utils";
|
||||
|
||||
// 为了优化性能,存储到缓存时删除了code与value
|
||||
export interface ScriptMatchInfo extends ScriptRunResouce {
|
||||
@ -29,6 +29,12 @@ export interface ScriptMatchInfo extends ScriptRunResouce {
|
||||
customizeExcludeMatches: string[];
|
||||
}
|
||||
|
||||
export interface EmitEventRequest {
|
||||
uuid: string;
|
||||
event: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export class RuntimeService {
|
||||
scriptDAO: ScriptDAO = new ScriptDAO();
|
||||
|
||||
@ -132,6 +138,22 @@ export class RuntimeService {
|
||||
return sendMessage(new ExtensionContentMessageSend(tabId, options), "content/runtime/" + action, data);
|
||||
}
|
||||
|
||||
// 给指定脚本触发事件
|
||||
EmitEventToTab(
|
||||
tabId: number,
|
||||
req: EmitEventRequest,
|
||||
options?: {
|
||||
documentId?: string;
|
||||
frameId?: number;
|
||||
}
|
||||
) {
|
||||
if (tabId === -1) {
|
||||
// 如果是-1, 代表给offscreen发送消息
|
||||
return sendMessage(this.sender, "offscreen/runtime/emitEvent", req);
|
||||
}
|
||||
return sendMessage(new ExtensionContentMessageSend(tabId, options), "content/runtime/emitEvent", req);
|
||||
}
|
||||
|
||||
async getPageScriptUuidByUrl(url: string, includeCustomize?: boolean) {
|
||||
const match = await this.loadScriptMatchInfo();
|
||||
// 匹配当前页面的脚本
|
||||
|
@ -19,7 +19,7 @@ import { MessageQueue } from "@Packages/message/message_queue";
|
||||
import { InstallSource } from ".";
|
||||
import { ResourceService } from "./resource";
|
||||
import { ValueService } from "./value";
|
||||
import { compileScriptCode } from "@App/runtime/content/utils";
|
||||
import { compileScriptCode } from "../content/utils";
|
||||
|
||||
export class ScriptService {
|
||||
logger: Logger;
|
||||
|
@ -2,13 +2,13 @@ import LoggerCore from "@App/app/logger/core";
|
||||
import Logger from "@App/app/logger/logger";
|
||||
import { Script, SCRIPT_TYPE_NORMAL, ScriptDAO } from "@App/app/repo/scripts";
|
||||
import { ValueDAO } from "@App/app/repo/value";
|
||||
import { getStorageName } from "@App/runtime/utils";
|
||||
import { Group, MessageSend } from "@Packages/message/server";
|
||||
import { RuntimeService } from "./runtime";
|
||||
import { PopupService } from "./popup";
|
||||
import { ValueUpdateData, ValueUpdateSender } from "@App/runtime/content/exec_script";
|
||||
import { sendMessage } from "@Packages/message/client";
|
||||
import Cache from "@App/app/cache";
|
||||
import { getStorageName } from "@App/pkg/utils/utils";
|
||||
import { ValueUpdateData, ValueUpdateSender } from "../content/exec_script";
|
||||
|
||||
export class ValueService {
|
||||
logger: Logger;
|
||||
@ -67,21 +67,18 @@ export class ValueService {
|
||||
uuid,
|
||||
storageName: storageName,
|
||||
};
|
||||
// 判断是后台脚本还是前台脚本
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
// 推送到所有加载了本脚本的tab中
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
// 推送到所有加载了本脚本的tab中
|
||||
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);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 推送到offscreen中
|
||||
sendMessage(this.send, "offscreen/runtime/valueUpdate", sendData);
|
||||
}
|
||||
});
|
||||
// 推送到offscreen中
|
||||
sendMessage(this.send, "offscreen/runtime/valueUpdate", sendData);
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
Reference in New Issue
Block a user