runtime
Some checks failed
test / Run tests (push) Failing after 17s
build / Build (push) Failing after 24s

This commit is contained in:
2025-01-24 16:49:20 +08:00
parent af15d67cb3
commit 415f00a3d1
33 changed files with 1115 additions and 4118 deletions

View File

@ -22,6 +22,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@reduxjs/toolkit": "^2.3.0",
"cron": "^3.2.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"dexie": "^4.0.10",
"eventemitter3": "^5.0.1",
@ -45,6 +46,7 @@
"@rspack/cli": "^1.0.14",
"@rspack/core": "^1.0.14",
"@types/chrome": "^0.0.279",
"@types/crypto-js": "^4.2.2",
"@types/node": "^22.10.2",
"@types/pako": "^2.0.3",
"@types/react": "^18.2.48",

16
pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ importers:
cron:
specifier: ^3.2.1
version: 3.2.1
crypto-js:
specifier: ^4.2.0
version: 4.2.0
dayjs:
specifier: ^1.11.13
version: 1.11.13
@ -90,6 +93,9 @@ importers:
'@types/chrome':
specifier: ^0.0.279
version: 0.0.279
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/node':
specifier: ^22.10.2
version: 22.10.2
@ -1029,6 +1035,9 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@ -1724,6 +1733,9 @@ packages:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-tree@3.0.1:
resolution: {integrity: sha512-8Fxxv+tGhORlshCdCwnNJytvlvq46sOLSYEx2ZIGurahWvMucSRnyjPA3AmrMq4VPRYbHVpWj5VkiVasrM2H4Q==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@ -4603,6 +4615,8 @@ snapshots:
'@types/cookie@0.6.0': {}
'@types/crypto-js@4.2.2': {}
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@ -5607,6 +5621,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypto-js@4.2.0: {}
css-tree@3.0.1:
dependencies:
mdn-data: 2.12.1

14
src/app/const.ts Normal file
View File

@ -0,0 +1,14 @@
import { version } from "../../package.json";
export const ExtVersion = version;
export const ExtServer = "https://ext.scriptcat.org/";
export const ExternalWhitelist = [
"greasyfork.org",
"scriptcat.org",
"tampermonkey.net.cn",
"openuserjs.org",
];
export const ExternalMessage = "externalMessage";

View File

@ -1,5 +1,5 @@
export abstract class Repo<T> {
constructor(private prefix: string) {
constructor(protected prefix: string) {
if (!prefix.endsWith(":")) {
this.prefix += ":";
}

View File

@ -1,14 +1,15 @@
import { DAO, db } from "./dao";
import { Repo } from "./repo";
import { v5 as uuidv5 } from "uuid";
export type ResourceType = "require" | "require-css" | "resource";
export interface Resource {
id: number;
url: string;
url: string; // key
content: string;
base64: string;
hash: ResourceHash;
type: ResourceType;
link: { [key: string]: boolean }; // 关联的脚本
contentType: string;
createtime: number;
updatetime?: number;
@ -22,11 +23,18 @@ export interface ResourceHash {
sha512: string;
}
export class ResourceDAO extends DAO<Resource> {
public tableName = "resource";
const ResourceNamespace = "76f45084-91b1-42c1-8be8-cbcc54b171f0";
export class ResourceDAO extends Repo<Resource> {
constructor() {
super();
this.table = db.table(this.tableName);
super("resource");
}
protected joinKey(key: string) {
return this.prefix + uuidv5(key, ResourceNamespace);
}
save(resource: Resource) {
return super._save(resource.url, resource);
}
}

View File

@ -1,17 +0,0 @@
import { DAO, db } from "./dao";
export interface ResourceLink {
id: number;
url: string;
scriptId: number;
createtime?: number;
}
export class ResourceLinkDAO extends DAO<ResourceLink> {
public tableName = "resourceLink";
constructor() {
super();
this.table = db.table(this.tableName);
}
}

View File

@ -41,10 +41,8 @@ export interface Config {
export type UserConfig = { [key: string]: { [key: string]: Config } };
export interface Script {
// id: number; // 脚本id mv3迁移为chrome.storage后舍弃
uuid: string; // 脚本uuid,通过脚本uuid识别唯一脚本
name: string; // 脚本名称
code: string; // 脚本执行代码
namespace: string; // 脚本命名空间
author?: string; // 脚本作者
originDomain?: string; // 脚本来源域名
@ -67,13 +65,18 @@ export interface Script {
nextruntime?: number; // 脚本下一次运行时间戳
}
// 分开存储脚本代码
export interface ScriptCode {
uuid: string;
code: string; // 脚本执行代码
}
// 脚本运行时的资源,包含已经编译好的脚本与脚本需要的资源
export interface ScriptRunResouce extends Script {
grantMap: { [key: string]: string };
code: string;
value: { [key: string]: Value };
flag: string;
resource: { [key: string]: Resource };
sourceCode: string;
}
export class ScriptDAO extends Repo<Script> {
@ -113,3 +116,14 @@ export class ScriptDAO extends Repo<Script> {
});
}
}
// 为了防止脚本代码数据量过大,单独存储脚本代码
export class ScriptCodeDAO extends Repo<ScriptCode> {
constructor() {
super("scriptCode");
}
public save(val: ScriptCode) {
return super._save(val.uuid, val);
}
}

View File

@ -1,7 +1,6 @@
import Dexie from "dexie";
import { DAO, db } from "./dao";
import { Repo } from "./repo";
export interface Value {
export interface OldValue {
id: number;
scriptId: number;
storageName?: string;
@ -11,15 +10,16 @@ export interface Value {
updatetime: number;
}
export class ValueDAO extends DAO<Value> {
public tableName = "value";
export interface Value {
uuid: string;
storageName?: string;
data: { [key: string]: any };
createtime: number;
updatetime: number;
}
constructor(table?: Dexie.Table<Value, number>) {
super();
if (table) {
this.table = table;
} else {
this.table = db.table(this.tableName);
}
export class ValueDAO extends Repo<Value> {
constructor() {
super("value");
}
}

View File

@ -1,6 +1,24 @@
import { WindowMessage } from "@Packages/message/window_message";
import { sendMessage } from "../utils";
import { SCRIPT_RUN_STATUS } from "@App/app/repo/scripts";
export function preparationSandbox(msg: WindowMessage) {
return sendMessage(msg, "preparationSandbox");
}
// 代理发送消息到ServiceWorker
export function sendMessageToServiceWorker(msg: WindowMessage, action: string, data?: any) {
return sendMessage(msg, "sendMessageToServiceWorker", { action, data });
}
// 代理连接ServiceWorker
export function connectServiceWorker(msg: WindowMessage) {
return sendMessage(msg, "connectServiceWorker");
}
export function proxyUpdateRunStatus(
msg: WindowMessage,
data: { uuid: string; runStatus: SCRIPT_RUN_STATUS; error?: any; nextruntime?: number }
) {
return sendMessageToServiceWorker(msg, "updateRunStatus", data);
}

View File

@ -5,6 +5,7 @@ import { Logger, LoggerDAO } from "@App/app/repo/logger";
import { WindowMessage } from "@Packages/message/window_message";
import { ExtensionMessage } from "@Packages/message/extension_message";
import { ServiceWorkerClient } from "../service_worker/client";
import { sendMessage } from "@Packages/message/client";
// offscreen环境的管理器
export class OffscreenManager {
@ -31,11 +32,16 @@ export class OffscreenManager {
serviceWorker.preparationOffscreen();
}
sendMessageToServiceWorker(data: { action: string; data: any }) {
return sendMessage(data.action, data.data);
}
initManager() {
// 监听消息
const group = this.api.group("offscreen");
this.windowApi.on("logger", this.logger.bind(this));
this.windowApi.on("preparationSandbox", this.preparationSandbox.bind(this));
this.windowApi.on("sendMessageToServiceWorker", this.sendMessageToServiceWorker.bind(this));
const script = new ScriptService(group.group("script"), this.mq, this.windowMessage, this.broker);
script.init();
}

View File

@ -3,13 +3,23 @@ import Logger from "@App/app/logger/logger";
import { Broker, MessageQueue } from "@Packages/message/message_queue";
import { Group } from "@Packages/message/server";
import { WindowMessage } from "@Packages/message/window_message";
import { ScriptClient, subscribeScriptEnable } from "../service_worker/client";
import { SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts";
import {
ResourceClient,
ScriptClient,
subscribeScriptEnable,
subscribeScriptInstall,
ValueClient,
} from "../service_worker/client";
import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts";
import { disableScript, enableScript } from "../sandbox/client";
export class ScriptService {
logger: Logger;
scriptClient: ScriptClient = new ScriptClient();
resourceClient: ResourceClient = new ResourceClient();
valueClient: ValueClient = new ValueClient();
constructor(
private group: Group,
private mq: MessageQueue,
@ -21,16 +31,26 @@ export class ScriptService {
async init() {
subscribeScriptEnable(this.broker, async (data) => {
const info = await new ScriptClient().info(data.uuid);
const info = await this.scriptClient.info(data.uuid);
if (info.type === SCRIPT_TYPE_NORMAL) {
return;
}
if (data.enable) {
// 发送给沙盒运行
enableScript(this.windowMessage, info);
// 构造脚本运行资源,发送给沙盒运行
enableScript(this.windowMessage, await this.scriptClient.getScriptRunResource(info));
} else {
// 发送给沙盒停止
disableScript(this.windowMessage, info);
disableScript(this.windowMessage, info.uuid);
}
});
subscribeScriptInstall(this.broker, async (data) => {
// 判断是开启还是关闭
if (data.script.status === SCRIPT_STATUS_ENABLE) {
// 构造脚本运行资源,发送给沙盒运行
enableScript(this.windowMessage, await this.scriptClient.getScriptRunResource(data.script));
} else {
// 发送给沙盒停止
disableScript(this.windowMessage, data.script.uuid);
}
});
}

View File

@ -1,11 +1,11 @@
import { Script } from "@App/app/repo/scripts";
import { ScriptRunResouce } from "@App/app/repo/scripts";
import { WindowMessage } from "@Packages/message/window_message";
import { sendMessage } from "../utils";
export function enableScript(msg: WindowMessage, data: Script) {
export function enableScript(msg: WindowMessage, data: ScriptRunResouce) {
return sendMessage(msg, "enableScript", data);
}
export function disableScript(msg: WindowMessage, data: Script) {
return sendMessage(msg, "disableScript", data);
export function disableScript(msg: WindowMessage, uuid: string) {
return sendMessage(msg, "disableScript", uuid);
}

View File

@ -1,10 +1,18 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { Script, SCRIPT_TYPE_BACKGROUND, ScriptRunResouce } from "@App/app/repo/scripts";
import {
SCRIPT_RUN_STATUS_COMPLETE,
SCRIPT_RUN_STATUS_ERROR,
SCRIPT_RUN_STATUS_RUNNING,
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";
export class Runtime {
cronJob: Map<string, Array<CronJob>> = new Map();
@ -55,34 +63,219 @@ export class Runtime {
}
}
removeRetryList(scriptId: number) {
removeRetryList(uuid: string) {
for (let i = 0; i < this.retryList.length; i += 1) {
if (this.retryList[i].script.id === scriptId) {
if (this.retryList[i].script.uuid === uuid) {
this.retryList.splice(i, 1);
i -= 1;
}
}
}
enableScript(data: Script) {
// 开启脚本, 判断脚本是后台脚本还是定时脚本
if (data.type === SCRIPT_TYPE_BACKGROUND) {
async enableScript(script: ScriptRunResouce) {
// 开启脚本
// 如果正在运行,先释放
if (this.execScripts.has(script.uuid)) {
await this.disableScript(script.uuid);
}
if (script.type === SCRIPT_TYPE_BACKGROUND) {
// 后台脚本直接运行起来
return this.execScript(script);
} else {
// 定时脚本加入定时任务
return this.crontabScript(script);
}
eval("console.log('hello')");
console.log("enableScript", data);
}
disableScript(data: Script) {
// 关闭脚本, 判断脚本是后台脚本还是定时脚本
if (data.type === SCRIPT_TYPE_BACKGROUND) {
// 后台脚本直接停止
} else {
// 定时脚本停止定时任务
disableScript(uuid: string) {
// 关闭脚本
// 停止定时任务
this.stopCronJob(uuid);
// 移除重试队列
this.removeRetryList(uuid);
// 发送运行状态变更
proxyUpdateRunStatus(this.windowMessage, { uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE });
// 停止脚本运行
return this.stopScript(uuid);
}
// 执行脚本
async execScript(script: ScriptRunResouce, execOnce?: boolean) {
const logger = this.logger.with({ script: script.uuid, name: script.name });
if (this.execScripts.has(script.uuid)) {
// 释放掉资源
// 暂未实现执行完成后立马释放,会在下一次执行时释放
await this.stopScript(script.uuid);
}
console.log("disableScript", data);
const exec = new BgExecScriptWarp(script);
this.execScripts.set(script.uuid, exec);
proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_RUNNING });
// 修改掉脚本掉最后运行时间, 数据库也需要修改
script.lastruntime = new Date().getTime();
const ret = exec.exec();
if (ret instanceof Promise) {
ret
.then((resp) => {
// 发送执行完成消息
proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE });
logger.info("exec script complete", {
value: resp,
});
})
.catch((err) => {
// 发送执行完成+错误消息
let errMsg;
let nextruntime = 0;
if (err instanceof CATRetryError) {
// @ts-ignore
errMsg = { error: err.msg };
if (!execOnce) {
// 下一次执行时间
// @ts-ignore
nextruntime = err.time.getTime();
script.nextruntime = nextruntime;
this.joinRetryList(script);
}
} else {
errMsg = Logger.E(err);
}
logger.error("exec script error", errMsg);
proxyUpdateRunStatus(this.windowMessage, {
uuid: script.uuid,
runStatus: SCRIPT_RUN_STATUS_ERROR,
error: errMsg,
nextruntime,
});
// 错误还是抛出,方便排查
throw err;
});
} else {
logger.warn("backscript return not promise");
}
return ret;
}
crontabScript(script: ScriptRunResouce) {
// 执行定时脚本 运行表达式
if (!script.metadata.crontab) {
throw new Error("错误的crontab表达式");
}
// 如果有nextruntime,则加入重试队列
this.joinRetryList(script);
let flag = false;
const cronJobList: Array<CronJob> = [];
script.metadata.crontab.forEach((val) => {
let oncePos = 0;
let crontab = val;
if (crontab.indexOf("once") !== -1) {
const vals = crontab.split(" ");
vals.forEach((item, index) => {
if (item === "once") {
oncePos = index;
}
});
if (vals.length === 5) {
oncePos += 1;
}
crontab = crontab.replace(/once/g, "*");
}
try {
const cron = new CronJob(crontab, this.crontabExec(script, oncePos));
cron.start();
cronJobList.push(cron);
} catch (e) {
flag = true;
this.logger.error(
"create cronjob failed",
{
uuid: script.uuid,
crontab: val,
},
Logger.E(e)
);
}
});
if (cronJobList.length !== script.metadata.crontab.length) {
// 有表达式失败了
cronJobList.forEach((crontab) => {
crontab.stop();
});
} else {
this.cronJob.set(script.uuid, cronJobList);
}
return Promise.resolve(!flag);
}
crontabExec(script: ScriptRunResouce, oncePos: number) {
if (oncePos) {
return () => {
// 没有最后一次执行时间表示之前都没执行过,直接执行
if (!script.lastruntime) {
this.execScript(script);
return;
}
const now = new Date();
const last = new Date(script.lastruntime);
let flag = false;
// 根据once所在的位置去判断执行
switch (oncePos) {
case 1: // 每分钟
flag = last.getMinutes() !== now.getMinutes();
break;
case 2: // 每小时
flag = last.getHours() !== now.getHours();
break;
case 3: // 每天
flag = last.getDay() !== now.getDay();
break;
case 4: // 每月
flag = last.getMonth() !== now.getMonth();
break;
case 5: // 每周
flag = this.getWeek(last) !== this.getWeek(now);
break;
default:
}
if (flag) {
this.execScript(script);
}
};
}
return () => {
this.execScript(script);
};
}
// 获取本周是第几周
getWeek(date: Date) {
const nowDate = new Date(date);
const firstDay = new Date(date);
firstDay.setMonth(0); // 设置1月
firstDay.setDate(1); // 设置1号
const diffDays = Math.ceil((nowDate.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000));
const week = Math.ceil(diffDays / 7);
return week === 0 ? 1 : week;
}
// 停止计时器
stopCronJob(uuid: string) {
const list = this.cronJob.get(uuid);
if (list) {
list.forEach((val) => {
val.stop();
});
this.cronJob.delete(uuid);
}
}
stopScript(uuid: string) {
const exec = this.execScripts.get(uuid);
if (!exec) {
return Promise.resolve(false);
}
exec.stop();
this.execScripts.delete(uuid);
return Promise.resolve(true);
}
init() {

View File

@ -1,7 +1,8 @@
import { Script } from "@App/app/repo/scripts";
import { Script, ScriptCode, ScriptRunResouce } from "@App/app/repo/scripts";
import { Client } from "@Packages/message/client";
import { InstallSource } from ".";
import { Broker } from "@Packages/message/message_queue";
import { Resource } from "@App/app/repo/resource";
export class ServiceWorkerClient extends Client {
constructor() {
@ -23,8 +24,8 @@ export class ScriptClient extends Client {
return this.do("getInstallInfo", uuid);
}
install(script: Script, upsertBy: InstallSource = "user") {
return this.do("install", { script, upsertBy });
install(script: Script, code: string, upsertBy: InstallSource = "user") {
return this.do("install", { script, code, upsertBy });
}
delete(uuid: string) {
@ -38,6 +39,34 @@ export class ScriptClient extends Client {
info(uuid: string): Promise<Script> {
return this.do("fetchInfo", uuid);
}
getCode(uuid: string): Promise<ScriptCode | undefined> {
return this.do("getCode", uuid);
}
getScriptRunResource(script: Script): Promise<ScriptRunResouce> {
return this.do("getScriptRunResource", script);
}
}
export class ResourceClient extends Client {
constructor() {
super("serviceWorker/resource");
}
getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
return this.do("getScriptResources", script);
}
}
export class ValueClient extends Client {
constructor() {
super("serviceWorker/value");
}
getScriptValue(script: Script) {
return this.do("getScriptValue", script);
}
}
export function subscribeScriptInstall(

View File

@ -2,6 +2,9 @@ import { Server } from "@Packages/message/server";
import { MessageQueue } from "@Packages/message/message_queue";
import { ScriptService } from "./script";
import { ExtensionMessage } from "@Packages/message/extension_message";
import { ResourceService } from "./resource";
import { ValueService } from "./value";
import { RuntimeService } from "./runtime";
export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode";
@ -19,7 +22,14 @@ export default class ServiceWorkerManager {
// 准备好环境
this.mq.emit("preparationOffscreen", {});
});
const script = new ScriptService(group.group("script"), this.mq);
const resource = new ResourceService(group.group("resource"), this.mq);
resource.init();
const value = new ValueService(group.group("value"), this.mq);
value.init();
const script = new ScriptService(group.group("script"), this.mq, value, resource);
script.init();
const runtime = new RuntimeService(group.group("runtime"), this.mq);
runtime.init();
}
}

View File

@ -0,0 +1,285 @@
import LoggerCore from "@App/app/logger/core";
import crypto from "crypto-js";
import Logger from "@App/app/logger/logger";
import { Resource, ResourceDAO, ResourceHash, ResourceType } from "@App/app/repo/resource";
import { Script } from "@App/app/repo/scripts";
import { MessageQueue } from "@Packages/message/message_queue";
import { Group } from "@Packages/message/server";
import { isText } from "@App/pkg/utils/istextorbinary";
import { blobToBase64 } from "@App/pkg/utils/script";
export class ResourceService {
logger: Logger;
resourceDAO: ResourceDAO = new ResourceDAO();
constructor(
private group: Group,
private mq: MessageQueue
) {
this.logger = LoggerCore.logger().with({ service: "resource" });
}
public async getResource(uuid: string, url: string, type: ResourceType): Promise<Resource | undefined> {
let res = await this.getResourceModel(url);
if (res) {
return Promise.resolve(res);
}
try {
res = await this.addResource(url, uuid, type);
if (res) {
return Promise.resolve(res);
}
} catch (e: any) {
// ignore
this.logger.error("get resource failed", { uuid, url }, Logger.E(e));
}
return Promise.resolve(undefined);
}
public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
return Promise.resolve({
...((await this.getResourceByType(script, "require")) || {}),
...((await this.getResourceByType(script, "require-css")) || {}),
...((await this.getResourceByType(script, "resource")) || {}),
});
}
async getResourceByType(script: Script, type: ResourceType): Promise<{ [key: string]: Resource }> {
if (!script.metadata[type]) {
return Promise.resolve({});
}
const ret: { [key: string]: Resource } = {};
await Promise.allSettled(
script.metadata[type].map(async (u) => {
if (type === "resource") {
const split = u.split(/\s+/);
if (split.length === 2) {
const res = await this.getResource(script.uuid, split[1], "resource");
if (res) {
ret[split[0]] = res;
}
}
} else {
const res = await this.getResource(script.uuid, u, type);
if (res) {
ret[u] = res;
}
}
})
);
return Promise.resolve(ret);
}
// 更新资源
async checkScriptResource(script: Script) {
return Promise.resolve({
...((await this.checkResourceByType(script, "require")) || {}),
...((await this.checkResourceByType(script, "require-css")) || {}),
...((await this.checkResourceByType(script, "resource")) || {}),
});
}
async checkResourceByType(script: Script, type: ResourceType): Promise<{ [key: string]: Resource }> {
if (!script.metadata[type]) {
return Promise.resolve({});
}
const ret: { [key: string]: Resource } = {};
await Promise.allSettled(
script.metadata[type].map(async (u) => {
if (type === "resource") {
const split = u.split(/\s+/);
if (split.length === 2) {
const res = await this.checkResource(script.uuid, split[1], "resource");
if (res) {
ret[split[0]] = res;
}
}
} else {
const res = await this.checkResource(script.uuid, u, type);
if (res) {
ret[u] = res;
}
}
})
);
return Promise.resolve(ret);
}
async checkResource(uuid: string, url: string, type: ResourceType) {
let res = await this.getResourceModel(url);
if (res) {
// 判断1分钟过期
if ((res.updatetime || 0) > new Date().getTime() - 1000 * 60) {
return Promise.resolve(res);
}
}
try {
res = await this.updateResource(url, uuid, type);
if (res) {
return Promise.resolve(res);
}
} catch (e: any) {
// ignore
this.logger.error("check resource failed", { uuid, url }, Logger.E(e));
}
return Promise.resolve(undefined);
}
async updateResource(url: string, uuid: string, type: ResourceType) {
// 重新加载
const u = this.parseUrl(url);
let result = await this.getResourceModel(u.url);
try {
const resource = await this.loadByUrl(u.url, type);
resource.updatetime = new Date().getTime();
if (!result) {
// 资源不存在,保存
resource.createtime = new Date().getTime();
resource.link = { uuid: true };
await this.resourceDAO.save(resource);
result = resource;
this.logger.info("reload new resource success", { url: u.url });
} else {
result.base64 = resource.base64;
result.content = resource.content;
result.contentType = resource.contentType;
result.hash = resource.hash;
result.updatetime = resource.updatetime;
result.link[uuid] = true;
await this.resourceDAO.update(result.url, result);
this.logger.info("reload resource success", {
url: u.url,
});
}
} catch (e) {
this.logger.error("load resource error", { url: u.url }, Logger.E(e));
throw e;
}
return Promise.resolve(result);
}
public async addResource(url: string, uuid: string, type: ResourceType): Promise<Resource> {
const u = this.parseUrl(url);
let result = await this.getResourceModel(u.url);
// 资源不存在,重新加载
if (!result) {
try {
const resource = await this.loadByUrl(u.url, type);
resource.link[uuid] = true;
resource.createtime = new Date().getTime();
resource.updatetime = new Date().getTime();
await this.resourceDAO.save(resource);
result = resource;
this.logger.info("load resource success", { url: u.url });
} catch (e) {
this.logger.error("load resource error", { url: u.url }, Logger.E(e));
throw e;
}
}
return Promise.resolve(result);
}
async getResourceModel(url: string) {
const u = this.parseUrl(url);
const resource = await this.resourceDAO.get(u.url);
if (resource) {
// 校验hash
if (u.hash) {
if (
(u.hash.md5 && u.hash.md5 !== resource.hash.md5) ||
(u.hash.sha1 && u.hash.sha1 !== resource.hash.sha1) ||
(u.hash.sha256 && u.hash.sha256 !== resource.hash.sha256) ||
(u.hash.sha384 && u.hash.sha384 !== resource.hash.sha384) ||
(u.hash.sha512 && u.hash.sha512 !== resource.hash.sha512)
) {
resource.content = `console.warn("ScriptCat: couldn't load resource from URL ${url} due to a SRI error ");`;
}
}
return Promise.resolve(resource);
}
return Promise.resolve(undefined);
}
calculateHash(blob: Blob): Promise<ResourceHash> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsArrayBuffer(blob);
reader.onloadend = () => {
if (!reader.result) {
resolve({
md5: "",
sha1: "",
sha256: "",
sha384: "",
sha512: "",
});
} else {
resolve({
md5: crypto.MD5(<string>reader.result).toString(),
sha1: crypto.SHA1(<string>reader.result).toString(),
sha256: crypto.SHA256(<string>reader.result).toString(),
sha384: crypto.SHA384(<string>reader.result).toString(),
sha512: crypto.SHA512(<string>reader.result).toString(),
});
}
};
});
}
loadByUrl(url: string, type: ResourceType): Promise<Resource> {
const u = this.parseUrl(url);
return fetch(u.url)
.then(async (resp) => {
if (resp.status !== 200) {
throw new Error(`resource response status not 200:${resp.status}`);
}
return {
data: await resp.blob(),
headers: resp.headers,
};
})
.then(async (response) => {
const resource: Resource = {
url: u.url,
content: "",
contentType: (response.headers.get("content-type") || "application/octet-stream").split(";")[0],
hash: await this.calculateHash(<Blob>response.data),
base64: "",
link: {},
type,
createtime: new Date().getTime(),
};
const arrayBuffer = await (<Blob>response.data).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
if (isText(uint8Array)) {
resource.content = await (<Blob>response.data).text();
}
resource.base64 = (await blobToBase64(<Blob>response.data)) || "";
return resource;
});
}
parseUrl(url: string): {
url: string;
hash?: { [key: string]: string };
} {
const urls = url.split("#");
if (urls.length < 2) {
return { url: urls[0], hash: undefined };
}
const hashs = urls[1].split(/[,;]/);
const hash: { [key: string]: string } = {};
hashs.forEach((val) => {
const kv = val.split("=");
if (kv.length < 2) {
return;
}
hash[kv[0]] = kv[1].toLocaleLowerCase();
});
return { url: urls[0], hash };
}
init() {
this.group.on("getScriptResources", this.getScriptResources.bind(this));
}
}

View File

@ -0,0 +1,56 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { ScriptEnableCallbackValue } from "./client";
import { Group } from "@Packages/message/server";
import { Script, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, ScriptDAO } from "@App/app/repo/scripts";
export class RuntimeService {
scriptDAO: ScriptDAO = new ScriptDAO();
constructor(
private group: Group,
private mq: MessageQueue
) {}
async init() {
// 监听脚本开启
this.mq.addListener("enableScript", async (data: ScriptEnableCallbackValue) => {
const script = await this.scriptDAO.get(data.uuid);
if (!script) {
return;
}
// 如果是普通脚本, 在service worker中进行注册
// 如果是后台脚本, 在offscreen中进行处理
if (script.type === SCRIPT_TYPE_NORMAL) {
// 注册入页面脚本
if (data.enable) {
this.registryPageScript(script);
} else {
this.unregistryPageScript(script);
}
}
});
// 将开启的脚本发送一次enable消息
const scriptDao = new ScriptDAO();
const list = await scriptDao.all();
list.forEach((script) => {
if (script.status !== SCRIPT_STATUS_ENABLE || script.type !== SCRIPT_TYPE_NORMAL) {
return;
}
this.mq.publish("enableScript", { uuid: script.uuid, enable: true });
});
// 监听offscreen环境初始化, 初始化完成后, 再将后台脚本运行起来
this.mq.addListener("preparationOffscreen", () => {
list.forEach((script) => {
if (script.status !== SCRIPT_STATUS_ENABLE || script.type === SCRIPT_TYPE_NORMAL) {
return;
}
this.mq.publish("enableScript", { uuid: script.uuid, enable: true });
});
});
}
registryPageScript(script: Script) {}
unregistryPageScript(script: Script) {}
}

View File

@ -5,24 +5,34 @@ import Logger from "@App/app/logger/logger";
import LoggerCore from "@App/app/logger/core";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
import { openInCurrentTab } from "@App/pkg/utils/utils";
import { openInCurrentTab, randomString } from "@App/pkg/utils/utils";
import {
Script,
SCRIPT_RUN_STATUS,
SCRIPT_STATUS_DISABLE,
SCRIPT_STATUS_ENABLE,
SCRIPT_TYPE_NORMAL,
ScriptCodeDAO,
ScriptDAO,
ScriptRunResouce,
} from "@App/app/repo/scripts";
import { MessageQueue } from "@Packages/message/message_queue";
import { InstallSource } from ".";
import { ScriptEnableCallbackValue } from "./client";
import { ResourceService } from "./resource";
import { ValueService } from "./value";
import { compileScriptCode } from "@App/runtime/content/utils";
export class ScriptService {
logger: Logger;
scriptDAO: ScriptDAO = new ScriptDAO();
scriptCodeDAO: ScriptCodeDAO = new ScriptCodeDAO();
constructor(
private group: Group,
private mq: MessageQueue
private mq: MessageQueue,
private valueService: ValueService,
private resourceService: ResourceService
) {
this.logger = LoggerCore.logger().with({ service: "script" });
}
@ -143,7 +153,7 @@ export class ScriptService {
}
// 安装脚本
async installScript(param: { script: Script; upsertBy: InstallSource }) {
async installScript(param: { script: Script; code: string; upsertBy: InstallSource }) {
param.upsertBy = param.upsertBy || "user";
const { script, upsertBy } = param;
const logger = this.logger.with({
@ -225,6 +235,45 @@ export class ScriptService {
return script;
}
async updateRunStatus(params: { uuid: string; runStatus: SCRIPT_RUN_STATUS; error?: string; nextruntime?: number }) {
await new ScriptDAO().update(params.uuid, {
runStatus: params.runStatus,
lastruntime: new Date().getTime(),
error: params.error,
nextruntime: params.nextruntime,
});
this.mq.publish("updateRunStatus", params);
}
getCode(uuid: string) {
return this.scriptCodeDAO.get(uuid);
}
async buildScriptRunResource(script: Script): Promise<ScriptRunResouce> {
const ret: ScriptRunResouce = <ScriptRunResouce>Object.assign(script);
// 自定义配置
if (ret.selfMetadata) {
ret.metadata = { ...ret.metadata };
Object.keys(ret.selfMetadata).forEach((key) => {
ret.metadata[key] = ret.selfMetadata![key];
});
}
ret.value = await this.valueService.getScriptValue(ret);
ret.resource = await this.resourceService.getScriptResources(ret);
ret.flag = randomString(16);
const code = await this.getCode(ret.uuid);
if (!code) {
throw new Error("code is null");
}
ret.code = compileScriptCode(ret, code.code);
return Promise.resolve(ret);
}
async init() {
this.listenerScriptInstall();
@ -233,51 +282,8 @@ export class ScriptService {
this.group.on("delete", this.deleteScript.bind(this));
this.group.on("enable", this.enableScript.bind(this));
this.group.on("fetchInfo", this.fetchInfo.bind(this));
this.listenScript();
this.group.on("updateRunStatus", this.updateRunStatus.bind(this));
this.group.on("getCode", this.getCode.bind(this));
this.group.on("getScriptRunResource", this.buildScriptRunResource.bind(this));
}
// 监听脚本
async listenScript() {
// 监听脚本开启
this.mq.addListener("enableScript", async (data: ScriptEnableCallbackValue) => {
const script = await new ScriptDAO().findByUUID(data.uuid);
if (!script) {
return;
}
// 如果是普通脚本, 在service worker中进行注册
// 如果是后台脚本, 在offscreen中进行处理
if (script.type === SCRIPT_TYPE_NORMAL) {
// 注册入页面脚本
if (data.enable) {
this.registryPageScript(script);
} else {
this.unregistryPageScript(script);
}
}
});
// 将开启的脚本发送一次enable消息
const scriptDao = new ScriptDAO();
const list = await scriptDao.all();
list.forEach((script) => {
if (script.status !== SCRIPT_STATUS_ENABLE || script.type !== SCRIPT_TYPE_NORMAL) {
return;
}
this.mq.publish("enableScript", { uuid: script.uuid, enable: true });
});
// 监听offscreen环境初始化, 初始化完成后, 再将后台脚本运行起来
this.mq.addListener("preparationOffscreen", () => {
list.forEach((script) => {
if (script.status !== SCRIPT_STATUS_ENABLE || script.type === SCRIPT_TYPE_NORMAL) {
return;
}
this.mq.publish("enableScript", { uuid: script.uuid, enable: true });
});
});
}
registryPageScript(script: Script) {}
unregistryPageScript(script: Script) {}
}

View File

@ -0,0 +1,37 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { Script } from "@App/app/repo/scripts";
import { ValueDAO } from "@App/app/repo/value";
import { MessageQueue } from "@Packages/message/message_queue";
import { Group } from "@Packages/message/server";
export class ValueService {
logger: Logger;
valueDAO: ValueDAO = new ValueDAO();
constructor(
private group: Group,
private mq: MessageQueue
) {
this.logger = LoggerCore.logger().with({ service: "value" });
}
storageKey(script: Script): string {
if (script.metadata.storagename) {
return script.metadata.storagename[0];
}
return script.uuid;
}
async getScriptValue(script: Script) {
const ret = await this.valueDAO.get(this.storageKey(script));
if (!ret) {
return {};
}
return Promise.resolve(ret?.data);
}
init() {
this.group.on("getScriptValue", this.getScriptValue.bind(this));
}
}

View File

@ -22,6 +22,10 @@ function App() {
const [countdown, setCountdown] = useState<number>(-1);
// 脚本信息
const [upsertScript, setUpsertScript] = useState<Script | Subscribe>();
// 脚本代码
const [code, setCode] = useState<string>("");
// 对比代码
const [diffCode, setDiffCode] = useState<string>();
// 更新的情况下会有老版本的脚本信息
const [oldScript, setOldScript] = useState<Script | Subscribe>();
// 脚本开启状态
@ -133,12 +137,16 @@ function App() {
throw new Error("fetch script info failed");
}
// 如果是更新的情况下, 获取老版本的脚本信息
let prepare: { script: Script; oldScript?: Script } | { subscribe: Subscribe; oldSubscribe?: Subscribe };
let prepare:
| { script: Script; oldScript?: Script; oldScriptCode?: string }
| { subscribe: Subscribe; oldSubscribe?: Subscribe };
let action: Script | Subscribe;
if (info.userSubscribe) {
prepare = await prepareSubscribeByCode(info.code, info.url);
action = prepare.subscribe;
setOldScript(prepare.oldSubscribe);
setCode(prepare.subscribe.code);
setDiffCode(prepare.oldSubscribe?.code);
} else {
if (info.update) {
prepare = await prepareScriptByCode(info.code, info.url, info.uuid);
@ -147,6 +155,8 @@ function App() {
}
action = prepare.script;
setOldScript(prepare.oldScript);
setCode(info.code);
setDiffCode(prepare.oldScriptCode);
}
if (info.userSubscribe) {
setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe"));
@ -246,7 +256,7 @@ function App() {
return;
}
new ScriptClient()
.install(upsertScript as Script)
.install(upsertScript as Script, code)
.then(() => {
if (isUpdate) {
Message.success(t("install.update_success")!);
@ -362,7 +372,7 @@ function App() {
</Grid.Row>
</Grid.Col>
</Grid.Row>
<CodeEditor id="show-code" code={upsertScript?.code || undefined} diffCode={oldScript?.code || ""} />
<CodeEditor id="show-code" code={code || undefined} diffCode={diffCode || ""} />
</div>
</div>
);

View File

@ -0,0 +1,236 @@
/* eslint no-use-before-define:0 */
/* eslint-disable */
// copy from istextorbinary
// 由于未知原因,该包在jest中运行提示"Cannot find module",故将其代码简化并copy到此处
// Import
import type Buffer from "buffer";
export interface EncodingOpts {
/** Defaults to 24 */
chunkLength?: number;
/** If not provided, will check the start, beginning, and end */
chunkBegin?: number;
}
/**
* Determine if the filename and/or buffer is text.
* Determined by extension checks first (if filename is available), otherwise if unknown extension or no filename, will perform a slower buffer encoding detection.
* This order is done, as extension checks are quicker, and also because encoding checks cannot guarantee accuracy for chars between utf8 and utf16.
* The extension checks are performed using the resources https://github.com/bevry/textextensions and https://github.com/bevry/binaryextensions
* @param filename The filename for the file/buffer if available
* @param buffer The buffer for the file if available
* @returns Will be `null` if neither `filename` nor `buffer` were provided. Otherwise will be a boolean value with the detection result.
*/
export function isText(buffer: Uint8Array): boolean {
// Fallback to encoding if extension check was not enough
return getEncoding(buffer) === "utf8";
}
/**
* Determine if the filename and/or buffer is binary.
* Determined by extension checks first (if filename is available), otherwise if unknown extension or no filename, will perform a slower buffer encoding detection.
* This order is done, as extension checks are quicker, and also because encoding checks cannot guarantee accuracy for chars between utf8 and utf16.
* The extension checks are performed using the resources https://github.com/bevry/textextensions and https://github.com/bevry/binaryextensions
* @param filename The filename for the file/buffer if available
* @param buffer The buffer for the file if available
* @returns Will be `null` if neither `filename` nor `buffer` were provided. Otherwise will be a boolean value with the detection result.
*/
export function isBinary(buffer: Buffer) {
const text = isText(buffer);
if (text == null) return null;
return !text;
}
/**
* Get the encoding of a buffer.
* Checks the start, middle, and end of the buffer for characters that are unrecognized within UTF8 encoding.
* History has shown that inspection at all three locations is necessary.
* @returns Will be `null` if `buffer` was not provided. Otherwise will be either `'utf8'` or `'binary'`
*/
export function getEncoding(
buffer: Uint8Array,
opts?: EncodingOpts
): "utf8" | "binary" | null {
// Check
if (!buffer) return null;
// Prepare
const textEncoding = "utf8";
const binaryEncoding = "binary";
const chunkLength = opts?.chunkLength ?? 24;
let chunkBegin = opts?.chunkBegin ?? 0;
// Discover
if (opts?.chunkBegin == null) {
// Start
let encoding = getEncoding(buffer, { chunkLength, chunkBegin });
if (encoding === textEncoding) {
// Middle
chunkBegin = Math.max(0, Math.floor(buffer.length / 2) - chunkLength);
encoding = getEncoding(buffer, {
chunkLength,
chunkBegin,
});
if (encoding === textEncoding) {
// End
chunkBegin = Math.max(0, buffer.length - chunkLength);
encoding = getEncoding(buffer, {
chunkLength,
chunkBegin,
});
}
}
// Return
return encoding;
} else {
// Extract
chunkBegin = getChunkBegin(buffer, chunkBegin);
if (chunkBegin === -1) {
return binaryEncoding;
}
const chunkEnd = getChunkEnd(
buffer,
Math.min(buffer.length, chunkBegin + chunkLength)
);
if (chunkEnd > buffer.length) {
return binaryEncoding;
}
const contentChunkUTF8 = buffer.slice(chunkBegin, chunkEnd);
// Detect encoding
for (let i = 0; i < contentChunkUTF8.length; ++i) {
const charCode = contentChunkUTF8[i];
if (charCode === 65533 || charCode <= 8) {
// 8 and below are control characters (e.g. backspace, null, eof, etc.)
// 65533 is the unknown character
// console.log(charCode, contentChunkUTF8[i])
return binaryEncoding;
}
}
// Return
return textEncoding;
}
}
// ====================================
// The functions below are created to handle multibyte utf8 characters.
// To understand how the encoding works, check this article: https://en.wikipedia.org/wiki/UTF-8#Encoding
// @todo add documentation for these
function getChunkBegin(buf: Uint8Array, chunkBegin: number) {
// If it's the beginning, just return.
if (chunkBegin === 0) {
return 0;
}
if (!isLaterByteOfUtf8(buf[chunkBegin])) {
return chunkBegin;
}
let begin = chunkBegin - 3;
if (begin >= 0) {
if (isFirstByteOf4ByteChar(buf[begin])) {
return begin;
}
}
begin = chunkBegin - 2;
if (begin >= 0) {
if (
isFirstByteOf4ByteChar(buf[begin]) ||
isFirstByteOf3ByteChar(buf[begin])
) {
return begin;
}
}
begin = chunkBegin - 1;
if (begin >= 0) {
// Is it a 4-byte, 3-byte utf8 character?
if (
isFirstByteOf4ByteChar(buf[begin]) ||
isFirstByteOf3ByteChar(buf[begin]) ||
isFirstByteOf2ByteChar(buf[begin])
) {
return begin;
}
}
return -1;
}
function getChunkEnd(buf: Uint8Array, chunkEnd: number) {
// If it's the end, just return.
if (chunkEnd === buf.length) {
return chunkEnd;
}
let index = chunkEnd - 3;
if (index >= 0) {
if (isFirstByteOf4ByteChar(buf[index])) {
return chunkEnd + 1;
}
}
index = chunkEnd - 2;
if (index >= 0) {
if (isFirstByteOf4ByteChar(buf[index])) {
return chunkEnd + 2;
}
if (isFirstByteOf3ByteChar(buf[index])) {
return chunkEnd + 1;
}
}
index = chunkEnd - 1;
if (index >= 0) {
if (isFirstByteOf4ByteChar(buf[index])) {
return chunkEnd + 3;
}
if (isFirstByteOf3ByteChar(buf[index])) {
return chunkEnd + 2;
}
if (isFirstByteOf2ByteChar(buf[index])) {
return chunkEnd + 1;
}
}
return chunkEnd;
}
function isFirstByteOf4ByteChar(byte: number) {
// eslint-disable-next-line no-bitwise
return byte >> 3 === 30; // 11110xxx?
}
function isFirstByteOf3ByteChar(byte: number) {
// eslint-disable-next-line no-bitwise
return byte >> 4 === 14; // 1110xxxx?
}
function isFirstByteOf2ByteChar(byte: number) {
// eslint-disable-next-line no-bitwise
return byte >> 5 === 6; // 110xxxxx?
}
function isLaterByteOfUtf8(byte: number) {
// eslint-disable-next-line no-bitwise
return byte >> 6 === 2; // 10xxxxxx?
}

View File

@ -8,6 +8,7 @@ import {
SCRIPT_TYPE_BACKGROUND,
SCRIPT_TYPE_CRONTAB,
SCRIPT_TYPE_NORMAL,
ScriptCodeDAO,
ScriptDAO,
UserConfig,
} from "@App/app/repo/scripts";
@ -138,7 +139,6 @@ export async function fetchScriptInfo(
export function copyScript(script: Script, old: Script): Script {
const ret = script;
ret.id = old.id;
ret.uuid = old.uuid;
ret.createtime = old.createtime;
ret.lastruntime = old.lastruntime;
@ -204,7 +204,7 @@ export function prepareScriptByCode(
url: string,
uuid?: string,
override?: boolean
): Promise<{ script: Script; oldScript?: Script }> {
): Promise<{ script: Script; oldScript?: Script; oldScriptCode?: string }> {
const dao = new ScriptDAO();
return new Promise((resolve, reject) => {
const metadata = parseMetadata(code);
@ -254,10 +254,8 @@ export function prepareScriptByCode(
newUUID = uuidv4();
}
let script: Script = {
id: 0,
uuid: newUUID,
name: metadata.name[0],
code,
author: metadata.author && metadata.author[0],
namespace: metadata.namespace && metadata.namespace[0],
originDomain: domain,
@ -277,6 +275,7 @@ export function prepareScriptByCode(
};
const handler = async () => {
let old: Script | undefined;
let oldCode: string | undefined;
if (uuid) {
old = await dao.findByUUID(uuid);
if (!old && override) {
@ -293,6 +292,12 @@ export function prepareScriptByCode(
reject(new Error("脚本类型不匹配,普通脚本与后台脚本不能互相转变"));
return;
}
const scriptCode = await new ScriptCodeDAO().get(old.uuid);
if(!scriptCode) {
reject(new Error("旧的脚本代码不存在"));
return;
}
oldCode = scriptCode.code;
script = copyScript(script, old);
} else {
// 前台脚本默认开启
@ -301,7 +306,7 @@ export function prepareScriptByCode(
}
script.checktime = new Date().getTime();
}
resolve({ script, oldScript: old });
resolve({ script, oldScript: old, oldScriptCode: oldCode });
};
handler();
});

View File

@ -1,961 +0,0 @@
/* eslint-disable camelcase */
import Cache from "@App/app/cache";
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { Channel } from "@App/app/message/channel";
import { MessageHander, MessageSender } from "@App/app/message/message";
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import ValueManager from "@App/app/service/value/manager";
import CacheKey from "@App/pkg/utils/cache_key";
import { v4 as uuidv4 } from "uuid";
import { base64ToBlob } from "@App/pkg/utils/script";
import { isFirefox } from "@App/pkg/utils/utils";
import Hook from "@App/app/service/hook";
import IoC from "@App/app/ioc";
import { SystemConfig } from "@App/pkg/config/config";
import FileSystemFactory from "@Pkg/filesystem/factory";
import FileSystem from "@Pkg/filesystem/filesystem";
import { joinPath } from "@Pkg/filesystem/utils";
import i18next from "i18next";
import { i18nName } from "@App/locales/locales";
import { isWarpTokenError } from "@Pkg/filesystem/error";
import PermissionVerify, {
ConfirmParam,
IPermissionVerify,
} from "./permission_verify";
import {
dealFetch,
dealXhr,
getFetchHeader,
getIcon,
listenerWebRequest,
setXhrHeader,
} from "./utils";
// GMApi,处理脚本的GM API调用请求
export type MessageRequest = {
scriptId: number; // 脚本id
api: string;
runFlag: string;
params: any[];
};
export type Request = MessageRequest & {
script: Script;
sender: MessageSender;
};
export type Api = (request: Request, connect?: Channel) => Promise<any>;
export default class GMApi {
message: MessageHander;
script: ScriptDAO;
permissionVerify: IPermissionVerify;
valueManager: ValueManager;
logger: Logger = LoggerCore.getLogger({ component: "GMApi" });
static hook: Hook<"registerMenu" | "unregisterMenu"> = new Hook();
systemConfig: SystemConfig;
constructor(message: MessageHander, permissionVerify: IPermissionVerify) {
this.message = message;
this.script = new ScriptDAO();
this.permissionVerify = permissionVerify;
this.systemConfig = IoC.instance(SystemConfig) as SystemConfig;
// 证明是后台运行的,生成一个随机的headerFlag
if (permissionVerify instanceof PermissionVerify) {
this.systemConfig.scriptCatFlag = `x-cat-${uuidv4()}`;
}
this.valueManager = IoC.instance(ValueManager);
}
start() {
this.message.setHandler(
"gmApi",
async (_action: string, data: MessageRequest, sender: MessageSender) => {
const api = PermissionVerify.apis.get(data.api);
if (!api) {
return Promise.reject(new Error("api is not found"));
}
const req = await this.parseRequest(data, sender);
try {
await this.permissionVerify.verify(req, api);
} catch (e) {
this.logger.error("verify error", { api: data.api }, Logger.E(e));
return Promise.reject(e);
}
return api.api.call(this, req);
}
);
this.message.setHandlerWithChannel(
"gmApiChannel",
async (
connect: Channel,
_action: string,
data: MessageRequest,
sender: MessageSender
) => {
const api = PermissionVerify.apis.get(data.api);
if (!api) {
return connect.throw("api is not found");
}
const req = await this.parseRequest(data, sender);
try {
await this.permissionVerify.verify(req, api);
} catch (e: any) {
this.logger.error("verify error", { api: data.api }, Logger.E(e));
return connect.throw(e.message);
}
return api.api.call(this, req, connect);
}
);
// 只有background页才监听web请求
if (this.permissionVerify instanceof PermissionVerify) {
listenerWebRequest(this.systemConfig.scriptCatFlag);
}
// 处理sandbox来的CAT_fetchBlob和CAT_createBlobUrl
this.message.setHandler("CAT_createBlobUrl", (_: string, blob: Blob) => {
const url = URL.createObjectURL(blob);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 1000);
return Promise.resolve(url);
});
this.message.setHandler("CAT_fetchBlob", (_: string, url: string) => {
return fetch(url).then((data) => data.blob());
});
}
// 解析请求
async parseRequest(
data: MessageRequest,
sender: MessageSender
): Promise<Request> {
const script = await Cache.getInstance().getOrSet(
CacheKey.script(data.scriptId),
() => {
return this.script.findById(data.scriptId);
}
);
if (!script) {
return Promise.reject(new Error("script is not found"));
}
const req: Request = <Request>data;
req.script = script;
req.sender = sender;
return Promise.resolve(req);
}
@PermissionVerify.API()
GM_setValue(request: Request): Promise<any> {
if (!request.params || request.params.length !== 2) {
return Promise.reject(new Error("param is failed"));
}
const [key, value] = request.params;
const sender = <MessageSender & { runFlag: string }>request.sender;
sender.runFlag = request.runFlag;
return this.valueManager.setValue(request.script, key, value, sender);
}
// 处理GM_xmlhttpRequest fetch的情况,先只处理ReadableStream的情况
// 且不考虑复杂的情况
CAT_fetch(request: Request, channel: Channel): Promise<any> {
const config = <GMSend.XHRDetails>request.params[0];
const { url } = config;
return fetch(url, {
method: config.method || "GET",
body: <any>config.data,
headers: getFetchHeader(this.systemConfig.scriptCatFlag, config),
})
.then((resp) => {
const send = dealFetch(
this.systemConfig.scriptCatFlag,
config,
resp,
1
);
const reader = resp.body?.getReader();
if (!reader) {
throw new Error("read is not found");
}
const { scriptCatFlag } = this.systemConfig;
reader.read().then(function read({ done, value }) {
if (done) {
const data = dealFetch(scriptCatFlag, config, resp, 4);
channel.send({ event: "onreadystatechange", data });
channel.send({ event: "onload", data });
channel.send({ event: "onloadend", data });
channel.disChannel();
} else {
channel.send({ event: "onstream", data: Array.from(value) });
reader.read().then(read);
}
});
channel.send({ event: "onloadstart", data: send });
send.readyState = 2;
channel.send({ event: "onreadystatechange", data: send });
})
.catch((e) => {
channel.throw(e);
});
}
@PermissionVerify.API({
confirm: (request: Request) => {
const config = <GMSend.XHRDetails>request.params[0];
const url = new URL(config.url);
if (request.script.metadata.connect) {
const { connect } = request.script.metadata;
for (let i = 0; i < connect.length; i += 1) {
if (url.hostname.endsWith(connect[i])) {
return Promise.resolve(true);
}
}
}
const metadata: { [key: string]: string } = {};
metadata[i18next.t("script_name")] = i18nName(request.script);
metadata[i18next.t("request_domain")] = url.hostname;
metadata[i18next.t("request_url")] = config.url;
return Promise.resolve({
permission: "cors",
permissionValue: url.hostname,
title: i18next.t("script_accessing_cross_origin_resource"),
metadata,
describe: i18next.t("confirm_operation_description"),
wildcard: true,
permissionContent: i18next.t("domain"),
} as ConfirmParam);
},
alias: ["GM.xmlHttpRequest"],
})
async GM_xmlhttpRequest(request: Request, channel: Channel): Promise<any> {
const config = <GMSend.XHRDetails>request.params[0];
if (config.responseType === "stream") {
// 只有fetch支持ReadableStream
return this.CAT_fetch(request, channel);
}
const xhr = new XMLHttpRequest();
xhr.open(
config.method || "GET",
config.url,
true,
config.user || "",
config.password || ""
);
if (config.overrideMimeType) {
xhr.overrideMimeType(config.overrideMimeType);
}
if (config.responseType !== "json") {
xhr.responseType = config.responseType || "";
}
const deal = async (event: string, data?: any) => {
const response: any = await dealXhr(
this.systemConfig.scriptCatFlag,
config,
xhr
);
if (data) {
Object.keys(data).forEach((key) => {
response[key] = data[key];
});
}
channel.send({ event, data: response });
if (event === "onload") {
channel.disChannel();
}
};
xhr.onload = () => {
deal("onload");
};
xhr.onloadstart = () => {
deal("onloadstart");
};
xhr.onloadend = () => {
deal("onloadstart");
};
xhr.onabort = () => {
deal("onabort");
};
xhr.onerror = () => {
deal("onerror");
};
xhr.onprogress = (event) => {
const respond: GMTypes.XHRProgress = {
done: xhr.DONE,
lengthComputable: event.lengthComputable,
loaded: event.loaded,
total: event.total,
totalSize: event.total,
};
deal("onprogress", respond);
};
xhr.onreadystatechange = () => {
deal("onreadystatechange");
};
xhr.ontimeout = () => {
channel.send({ event: "ontimeout" });
};
setXhrHeader(this.systemConfig.scriptCatFlag, config, xhr);
if (config.timeout) {
xhr.timeout = config.timeout;
}
if (config.overrideMimeType) {
xhr.overrideMimeType(config.overrideMimeType);
}
if (config.dataType === "FormData") {
const data = new FormData();
if (config.data && config.data instanceof Array) {
config.data.forEach((val: GMSend.XHRFormData) => {
if (val.type === "file") {
data.append(val.key, base64ToBlob(val.val), val.filename);
} else {
data.append(val.key, val.val);
}
});
xhr.send(data);
}
} else if (config.dataType === "Blob") {
if (!config.data) {
return channel.throw("data is null");
}
const resp = await (await fetch(<string>config.data)).blob();
xhr.send(resp);
} else {
xhr.send(<string>config.data);
}
channel.setDisChannelHandler(() => {
xhr.abort();
});
return Promise.resolve();
}
@PermissionVerify.API({
listener() {
chrome.notifications.onClosed.addListener((id, user) => {
const ret = Cache.getInstance().get(`GM_notification:${id}`);
if (ret) {
const channel = <Channel>ret;
channel.send({ event: "done", id, user });
channel.disChannel();
Cache.getInstance().del(`GM_notification:${id}`);
}
});
chrome.notifications.onClicked.addListener((id) => {
const ret = Cache.getInstance().get(`GM_notification:${id}`);
if (ret) {
const channel = <Channel>ret;
channel.send({ event: "click", id, index: undefined });
channel.send({ event: "done", id, user: true });
channel.disChannel();
Cache.getInstance().del(`GM_notification:${id}`);
}
});
chrome.notifications.onButtonClicked.addListener((id, buttonIndex) => {
const ret = Cache.getInstance().get(`GM_notification:${id}`);
if (ret) {
const channel = <Channel>ret;
channel.send({ event: "click", id, index: buttonIndex });
channel.send({ event: "done", id, user: true });
channel.disChannel();
Cache.getInstance().del(`GM_notification:${id}`);
}
});
},
})
GM_notification(request: Request, channel: Channel): any {
if (request.params.length === 0) {
return channel.throw("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;
}
chrome.notifications.create(options, (notificationId) => {
Cache.getInstance().set(`GM_notification:${notificationId}`, channel);
channel.send({ event: "create", id: notificationId });
if (details.timeout) {
setTimeout(() => {
chrome.notifications.clear(notificationId);
channel.send({ event: "done", id: notificationId, user: false });
channel.disChannel();
Cache.getInstance().del(`GM_notification:${notificationId}`);
}, details.timeout);
}
});
return true;
}
@PermissionVerify.API()
GM_closeNotification(request: Request): Promise<boolean> {
chrome.notifications.clear(<string>request.params[0]);
const ret = Cache.getInstance().get(
`GM_notification:${<string>request.params[0]}`
);
if (ret) {
const channel = <Channel>ret;
channel.send({ event: "done", id: request.params[0], user: false });
Cache.getInstance().del(`GM_notification:${<string>request.params[0]}`);
}
return Promise.resolve(true);
}
@PermissionVerify.API()
GM_updateNotification(request: Request): Promise<boolean> {
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,
};
chrome.notifications.update(<string>id, options);
return Promise.resolve(true);
}
@PermissionVerify.API()
GM_log(request: Request): Promise<boolean> {
const message = request.params[0];
const level = request.params[1] || "info";
const labels = request.params[2] || {};
LoggerCore.getLogger(labels).log(level, message, {
scriptId: request.scriptId,
component: "GM_log",
});
return Promise.resolve(true);
}
@PermissionVerify.API({
listener: () => {
chrome.tabs.onRemoved.addListener((tabId) => {
const channel = <Channel>(
Cache.getInstance().get(`GM_openInTab:${tabId}`)
);
if (channel) {
channel.send({ event: "onclose" });
channel.disChannel();
Cache.getInstance().del(`GM_openInTab:${tabId}`);
}
});
},
})
GM_openInTab(request: Request, channel: Channel) {
const url = request.params[0];
const options = request.params[1] || {};
if (options.useOpen === true) {
const newWindow = window.open(url);
if (newWindow) {
// 由于不符合同源策略无法直接监听newWindow关闭事件因此改用CDP方法监听
// 由于window.open强制在前台打开标签因此获取状态为{ active:true }的标签即为新标签
chrome.tabs.query({ active: true }, ([tab]) => {
Cache.getInstance().set(`GM_openInTab:${tab.id}`, channel);
channel.send({ event: "oncreate", tabId: tab.id });
});
} else {
// 当新tab被浏览器阻止时window.open()会返回null 视为已经关闭
// 似乎在Firefox中禁止在background页面使用window.open()强制返回null
channel.send({ event: "onclose" });
channel.disChannel();
}
} else {
chrome.tabs.create({ url, active: options.active }, (tab) => {
Cache.getInstance().set(`GM_openInTab:${tab.id}`, channel);
channel.send({ event: "oncreate", tabId: tab.id });
});
}
}
@PermissionVerify.API({
link: "GM_openInTab",
})
async GM_closeInTab(request: Request): Promise<boolean> {
try {
await chrome.tabs.remove(<number>request.params[0]);
} catch (e) {
this.logger.error("GM_closeInTab", Logger.E(e));
}
return Promise.resolve(true);
}
static tabData = new Map<number, Map<number | string, any>>();
@PermissionVerify.API({
listener: () => {
chrome.tabs.onRemoved.addListener((tabId) => {
GMApi.tabData.forEach((value) => {
value.forEach((v, tabIdKey) => {
if (tabIdKey === tabId) {
value.delete(tabIdKey);
}
});
});
});
},
})
GM_getTab(request: Request) {
return Promise.resolve(
GMApi.tabData
.get(request.scriptId)
?.get(request.sender.tabId || request.sender.targetTag)
);
}
@PermissionVerify.API()
GM_saveTab(request: Request) {
const data = request.params[0];
const tabId = request.sender.tabId || request.sender.targetTag;
if (!GMApi.tabData.has(request.scriptId)) {
GMApi.tabData.set(request.scriptId, new Map());
}
GMApi.tabData.get(request.scriptId)?.set(tabId, data);
return Promise.resolve(true);
}
@PermissionVerify.API()
GM_getTabs(request: Request) {
if (!GMApi.tabData.has(request.scriptId)) {
return Promise.resolve({});
}
const resp: { [key: string | number]: object } = {};
GMApi.tabData.get(request.scriptId)?.forEach((value, key) => {
resp[key] = value;
});
return Promise.resolve(resp);
}
@PermissionVerify.API()
GM_download(request: Request, channel: Channel) {
const config = <GMTypes.DownloadDetails>request.params[0];
// blob本地文件直接下载
if (config.url.startsWith("blob:")) {
chrome.downloads.download(
{
url: config.url,
saveAs: config.saveAs,
filename: config.name,
},
() => {
channel.send({ event: "onload" });
}
);
return;
}
// 使用ajax下载blob,再使用download api创建下载
const xhr = new XMLHttpRequest();
xhr.open(config.method || "GET", config.url, true);
xhr.responseType = "blob";
const deal = (event: string, data?: any) => {
const removeXCat = new RegExp(`${this.systemConfig.scriptCatFlag}-`, "g");
const respond: any = {
finalUrl: xhr.responseURL || config.url,
readyState: <any>xhr.readyState,
status: xhr.status,
statusText: xhr.statusText,
responseHeaders: xhr.getAllResponseHeaders().replace(removeXCat, ""),
};
if (data) {
Object.keys(data).forEach((key) => {
respond[key] = data[key];
});
}
channel.send({ event, data: respond });
};
xhr.onload = () => {
deal("onload");
const url = URL.createObjectURL(xhr.response);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 6000);
chrome.downloads.download({
url,
saveAs: config.saveAs,
filename: config.name,
});
};
xhr.onerror = () => {
deal("onerror");
};
xhr.onprogress = (event) => {
const respond: GMTypes.XHRProgress = {
done: xhr.DONE,
lengthComputable: event.lengthComputable,
loaded: event.loaded,
total: event.total,
totalSize: event.total,
};
deal("onprogress", respond);
};
xhr.ontimeout = () => {
channel.send({ event: "ontimeout" });
};
setXhrHeader(this.systemConfig.scriptCatFlag, config, xhr);
if (config.timeout) {
xhr.timeout = config.timeout;
}
xhr.send();
channel.setDisChannelHandler(() => {
xhr.abort();
});
}
static clipboardData: { type?: string; data: string } | undefined;
@PermissionVerify.API({
listener() {
PermissionVerify.textarea.style.display = "none";
document.documentElement.appendChild(PermissionVerify.textarea);
document.addEventListener("copy", (e: ClipboardEvent) => {
if (!GMApi.clipboardData || !e.clipboardData) {
return;
}
e.preventDefault();
const { type, data } = GMApi.clipboardData;
e.clipboardData.setData(type || "text/plain", data);
GMApi.clipboardData = undefined;
});
},
})
GM_setClipboard(request: Request) {
return new Promise((resolve) => {
GMApi.clipboardData = {
type: request.params[1],
data: request.params[0],
};
PermissionVerify.textarea.focus();
document.execCommand("copy", false, <any>null);
resolve(undefined);
});
}
@PermissionVerify.API({
confirm(request: Request) {
if (request.params[0] === "store") {
return Promise.resolve(true);
}
const detail = <GMTypes.CookieDetails>request.params[1];
if (!detail.url && !detail.domain) {
return Promise.reject(new Error("there must be one of url or domain"));
}
let url: URL = <URL>{};
if (detail.url) {
url = new URL(detail.url);
} else {
url.host = detail.domain || "";
url.hostname = detail.domain || "";
}
let flag = false;
if (request.script.metadata.connect) {
const { connect } = request.script.metadata;
for (let i = 0; i < connect.length; i += 1) {
if (url.hostname.endsWith(connect[i])) {
flag = true;
break;
}
}
}
if (!flag) {
return Promise.reject(
new Error("hostname must be in the definition of connect")
);
}
const metadata: { [key: string]: string } = {};
metadata[i18next.t("script_name")] = i18nName(request.script);
metadata[i18next.t("request_domain")] = url.host;
return Promise.resolve({
permission: "cookie",
permissionValue: url.host,
title: i18next.t("access_cookie_content")!,
metadata,
describe: i18next.t("confirm_script_operation")!,
permissionContent: i18next.t("cookie_domain")!,
uuid: "",
});
},
})
GM_cookie(request: Request) {
return new Promise((resolve, reject) => {
const param = request.params;
if (param.length !== 2) {
reject(new Error("there must be two parameters"));
return;
}
const detail = <GMTypes.CookieDetails>request.params[1];
if (param[0] === "store") {
chrome.cookies.getAllCookieStores((res) => {
const data: any[] = [];
res.forEach((val) => {
if (detail.tabId) {
for (let n = 0; n < val.tabIds.length; n += 1) {
if (val.tabIds[n] === detail.tabId) {
data.push({ storeId: val.id });
break;
}
}
} else {
data.push({ storeId: val.id });
}
});
resolve(data);
});
return;
}
// url或者域名不能为空
if (detail.url) {
detail.url = detail.url.trim();
}
if (detail.domain) {
detail.domain = detail.domain.trim();
}
if (!detail.url && !detail.domain) {
reject(new Error("there must be one of url or domain"));
return;
}
switch (param[0]) {
case "list": {
chrome.cookies.getAll(
{
domain: detail.domain,
name: detail.name,
path: detail.path,
secure: detail.secure,
session: detail.session,
url: detail.url,
storeId: detail.storeId,
},
(cookies) => {
resolve(cookies);
}
);
break;
}
case "delete": {
if (!detail.url || !detail.name) {
reject(new Error("delete operation must have url and name"));
return;
}
chrome.cookies.remove(
{
name: detail.name,
url: detail.url,
storeId: detail.storeId,
},
() => {
resolve(undefined);
}
);
break;
}
case "set": {
if (!detail.url || !detail.name) {
reject(new Error("set operation must have name and value"));
return;
}
chrome.cookies.set(
{
url: detail.url,
name: detail.name,
domain: detail.domain,
value: detail.value,
expirationDate: detail.expirationDate,
path: detail.path,
httpOnly: detail.httpOnly,
secure: detail.secure,
storeId: detail.storeId,
},
() => {
resolve(undefined);
}
);
break;
}
default: {
reject(new Error("action can only be: get, set, delete, store"));
break;
}
}
});
}
@PermissionVerify.API()
GM_registerMenuCommand(request: Request, channel: Channel) {
GMApi.hook.trigger("registerMenu", request, channel);
channel.setDisChannelHandler(() => {
GMApi.hook.trigger("unregisterMenu", request.params[0], request);
});
return Promise.resolve();
}
@PermissionVerify.API()
GM_unregisterMenuCommand(request: Request) {
GMApi.hook.trigger("unregisterMenu", request.params[0], request);
}
@PermissionVerify.API()
CAT_userConfig(request: Request) {
chrome.tabs.create({
url: `/src/options.html#/?userConfig=${request.scriptId}`,
active: true,
});
}
@PermissionVerify.API({
confirm: (request: Request) => {
const [action, details] = request.params;
if (action === "config") {
return Promise.resolve(true);
}
const dir = details.baseDir ? details.baseDir : request.script.uuid;
const metadata: { [key: string]: string } = {};
metadata[i18next.t("script_name")] = i18nName(request.script);
return Promise.resolve({
permission: "file_storage",
permissionValue: dir,
title: i18next.t("script_operation_title"),
metadata,
describe: i18next.t("script_operation_description", { dir }),
wildcard: false,
permissionContent: i18next.t("script_permission_content"),
} as ConfirmParam);
},
alias: ["GM.xmlHttpRequest"],
})
// eslint-disable-next-line consistent-return
async CAT_fileStorage(request: Request, channel: Channel) {
const [action, details] = request.params;
if (action === "config") {
chrome.tabs.create({
url: `/src/options.html#/setting`,
active: true,
});
return Promise.resolve(true);
}
const fsConfig = this.systemConfig.catFileStorage;
if (fsConfig.status === "unset") {
return channel.throw({ code: 1, error: "file storage is disable" });
}
if (fsConfig.status === "error") {
return channel.throw({ code: 2, error: "file storge is error" });
}
let fs: FileSystem;
const baseDir = `ScriptCat/app/${
details.baseDir ? details.baseDir : request.script.uuid
}`;
try {
fs = await FileSystemFactory.create(
fsConfig.filesystem,
fsConfig.params[fsConfig.filesystem]
);
await FileSystemFactory.mkdirAll(fs, baseDir);
fs = await fs.openDir(baseDir);
} catch (e: any) {
if (isWarpTokenError(e)) {
fsConfig.status = "error";
this.systemConfig.catFileStorage = fsConfig;
return channel.throw({ code: 2, error: e.error.message });
}
return channel.throw({ code: 8, error: e.message });
}
switch (action) {
case "list":
fs.list()
.then((list) => {
list.forEach((file) => {
(<any>file).absPath = file.path;
file.path = joinPath(
file.path.substring(file.path.indexOf(baseDir) + baseDir.length)
);
});
channel.send({ action: "onload", data: list });
channel.disChannel();
})
.catch((e) => {
channel.throw({ code: 3, error: e.message });
});
break;
case "upload":
// eslint-disable-next-line no-case-declarations
const w = await fs.create(details.path);
w.write(await (await fetch(<string>details.data)).blob())
.then(() => {
channel.send({ action: "onload", data: true });
channel.disChannel();
})
.catch((e) => {
channel.throw({ code: 4, error: e.message });
});
break;
case "download":
// eslint-disable-next-line no-case-declarations, no-undef
const info = <CATType.FileStorageFileInfo>details.file;
fs = await fs.openDir(`${info.path}`);
// eslint-disable-next-line no-case-declarations
const r = await fs.open({
fsid: (<any>info).fsid,
name: info.name,
path: info.absPath,
size: info.size,
digest: info.digest,
createtime: info.createtime,
updatetime: info.updatetime,
});
r.read("blob")
.then((blob) => {
const url = URL.createObjectURL(blob);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 6000);
channel.send({ action: "onload", data: url });
channel.disChannel();
})
.catch((e) => {
channel.throw({ code: 5, error: e.message });
});
break;
case "delete":
fs.delete(`${details.path}`)
.then(() => {
channel.send({ action: "onload", data: true });
channel.disChannel();
})
.catch((e) => {
channel.throw({ code: 6, error: e.message });
});
break;
default:
channel.disChannel();
break;
}
}
}

View File

@ -1,410 +0,0 @@
// gm api 权限验证
import Cache from "@App/app/cache";
import { Permission, PermissionDAO } from "@App/app/repo/permission";
import { Script } from "@App/app/repo/scripts";
import CacheKey from "@App/pkg/utils/cache_key";
import { v4 as uuidv4 } from "uuid";
import MessageQueue from "@App/pkg/utils/message_queue";
import IoC from "@App/app/ioc";
import { MessageHander } from "@App/app/message/message";
import { Api, Request } from "./gm_api";
export interface ConfirmParam {
// 权限名
permission: string;
// 权限值
permissionValue?: string;
// 确认权限标题
title?: string;
// 权限详情内容
metadata?: { [key: string]: string };
// 权限描述
describe?: string;
// 是否通配
wildcard?: boolean;
// 权限内容
permissionContent?: string;
}
export interface UserConfirm {
allow: boolean;
type: number; // 1: 允许一次 2: 临时允许全部 3: 临时允许此 4: 永久允许全部 5: 永久允许此
}
export interface ApiParam {
// 默认提供的函数
default?: boolean;
// 是否只有后台环境中才能执行
background?: boolean;
// 是否需要弹出页面让用户进行确认
confirm?: (request: Request) => Promise<boolean | ConfirmParam>;
// 监听方法
listener?: () => void;
// 别名
alias?: string[];
// 关联
link?: string;
}
export interface ApiValue {
api: Api;
param: ApiParam;
}
export interface IPermissionVerify {
verify(request: Request, api: ApiValue): Promise<boolean>;
}
export default class PermissionVerify {
static apis: Map<string, ApiValue> = new Map();
static textarea: HTMLTextAreaElement = document.createElement("textarea");
public static API(param: ApiParam = {}) {
return (
target: any,
propertyName: string,
descriptor: PropertyDescriptor
) => {
const key = propertyName;
if (param.listener) {
param.listener();
}
PermissionVerify.apis.set(key, {
api: descriptor.value,
param,
});
// 兼容GM.*
const dot = key.replace("_", ".");
if (dot !== key) {
PermissionVerify.apis.set(dot, {
api: descriptor.value,
param,
});
if (param.alias) {
param.alias.push(dot);
} else {
param.alias = [dot];
}
}
// 处理别名
if (param.alias) {
param.alias.forEach((alias) => {
PermissionVerify.apis.set(alias, {
api: descriptor.value,
param,
});
});
}
};
}
permissionDAO: PermissionDAO;
// 确认队列
confirmQueue: MessageQueue<{
request: Request;
confirm: ConfirmParam | boolean;
resolve: (value: boolean) => void;
reject: (reason: any) => void;
}> = new MessageQueue();
removePermissionCache(scriptId: number) {
// 先删除缓存
Cache.getInstance()
.list()
.forEach((key) => {
if (key.startsWith(`permission:${scriptId.toString()}:`)) {
Cache.getInstance().del(key);
}
});
}
constructor() {
this.permissionDAO = new PermissionDAO();
// 监听用户确认消息
const message = <MessageHander>IoC.instance(MessageHander);
message.setHandler(
"permissionConfirm",
(_action, data: { uuid: string; userConfirm: UserConfirm }) => {
const confirm = this.confirmMap.get(data.uuid);
if (!confirm) {
if (data.userConfirm.type === 0) {
// 忽略
return Promise.resolve(undefined);
}
return Promise.reject(new Error("confirm not found"));
}
this.confirmMap.delete(data.uuid);
confirm.resolve(data.userConfirm);
return Promise.resolve(true);
}
);
// 监听获取用户确认消息
message.setHandler("getConfirm", (_action, uuid: string) => {
const data = this.confirmMap.get(uuid);
if (!data) {
return Promise.reject(new Error("uuid not found"));
}
// 查询允许统配的有多少个相同等待确认权限
let likeNum = 0;
if (data.confirm.wildcard) {
this.confirmQueue.list.forEach((value) => {
const confirm = value.confirm as ConfirmParam;
if (
confirm.wildcard &&
value.request.scriptId === data.script.id &&
confirm.permission === data.confirm.permission
) {
likeNum += 1;
}
});
}
return Promise.resolve({
script: data.script,
confirm: data.confirm,
likeNum,
});
});
// 监听删除权限
message.setHandler(
"deletePermission",
async (_action, data: { scriptId: number; confirm: ConfirmParam }) => {
// 先删除缓存
this.removePermissionCache(data.scriptId);
// 再删除数据库
const m = await this.permissionDAO.findOne({
scriptId: data.scriptId,
permission: data.confirm.permission,
permissionValue: data.confirm.permissionValue || "",
});
if (!m) {
return Promise.resolve(true);
}
await this.permissionDAO.delete(m.id);
return Promise.resolve(true);
}
);
// 监听添加权限
message.setHandler(
"addPermission",
async (_action, data: { scriptId: number; permission: Permission }) => {
// 先删除缓存
this.removePermissionCache(data.scriptId);
// 从数据库中查询是否有此权限
const m = await this.permissionDAO.findOne({
scriptId: data.scriptId,
permission: data.permission.permission,
permissionValue: data.permission.permissionValue || "",
});
if (!m) {
// 没有添加
await this.permissionDAO.save(data.permission);
return Promise.resolve(true);
}
// 有则更新
data.permission.id = m.id;
data.permission.createtime = m.createtime;
data.permission.updatetime = new Date().getTime();
this.permissionDAO.update(m.id, data.permission);
return Promise.resolve(true);
}
);
// 监听重置权限
message.setHandler(
"resetPermission",
async (_action, data: { scriptId: number }) => {
// 先删除缓存
this.removePermissionCache(data.scriptId);
// 从数据库中查询是否有此权限
await this.permissionDAO.delete({
scriptId: data.scriptId,
});
return Promise.resolve(true);
}
);
this.dealConfirmQueue();
}
// 验证是否有权限
verify(request: Request, api: ApiValue): Promise<boolean> {
if (api.param.default) {
return Promise.resolve(true);
}
// 没有其它条件,从metadata.grant中判断
const { grant } = request.script.metadata;
if (!grant) {
return Promise.reject(new Error("grant is undefined"));
}
for (let i = 0; i < grant.length; i += 1) {
if (
// 名称相等
grant[i] === request.api ||
// 别名相等
(api.param.alias && api.param.alias.includes(grant[i])) ||
// 有关联的
grant[i] === api.param.link
) {
// 需要用户确认
if (api.param.confirm) {
return this.pushConfirmQueue(request, api);
}
return Promise.resolve(true);
}
}
return Promise.reject(new Error("permission not requested"));
}
async dealConfirmQueue() {
// 处理确认队列
const data = await this.confirmQueue.pop();
if (!data) {
this.dealConfirmQueue();
return;
}
try {
const ret = await this.confirm(data.request, data.confirm);
data.resolve(ret);
} catch (e) {
data.reject(e);
}
this.dealConfirmQueue();
}
// 确认队列,为了防止一次性打开过多的窗口
async pushConfirmQueue(request: Request, api: ApiValue): Promise<boolean> {
const confirm = await api.param.confirm!(request);
if (confirm === true) {
return Promise.resolve(true);
}
return new Promise((resolve, reject) => {
this.confirmQueue.push({ request, confirm, resolve, reject });
});
}
async confirm(
request: Request,
confirm: boolean | ConfirmParam
): Promise<boolean> {
if (typeof confirm === "boolean") {
return confirm;
}
const cacheKey = CacheKey.permissionConfirm(request.script.id, confirm);
// 从数据库中查询是否有此权限
const ret = await Cache.getInstance().getOrSet(cacheKey, async () => {
let model = await this.permissionDAO.findOne({
scriptId: request.scriptId,
permission: confirm.permission,
permissionValue: confirm.permissionValue || "",
});
if (!model) {
// 允许通配
if (confirm.wildcard) {
model = await this.permissionDAO.findOne({
scriptId: request.scriptId,
permission: confirm.permission,
permissionValue: "*",
});
}
}
return Promise.resolve(model);
});
// 有查询到结果,进入判断,不再需要用户确认
if (ret) {
if (ret.allow) {
return Promise.resolve(true);
}
// 权限拒绝
return Promise.reject(new Error("permission denied"));
}
// 没有权限,则弹出页面让用户进行确认
const userConfirm = await this.confirmWindow(request.script, confirm);
// 成功存入数据库
const model = {
id: 0,
scriptId: request.scriptId,
permission: confirm.permission,
permissionValue: "",
allow: userConfirm.allow,
createtime: new Date().getTime(),
updatetime: 0,
};
switch (userConfirm.type) {
case 4:
case 2: {
// 通配
model.permissionValue = "*";
break;
}
case 5:
case 3: {
model.permissionValue = confirm.permissionValue || "";
break;
}
default:
break;
}
// 临时 放入缓存
if (userConfirm.type >= 2) {
Cache.getInstance().set(cacheKey, model);
}
// 总是 放入数据库
if (userConfirm.type >= 4) {
const oldConfirm = await this.permissionDAO.findOne({
scriptId: request.scriptId,
permission: model.permission,
permissionValue: model.permissionValue,
});
if (!oldConfirm) {
await this.permissionDAO.save(model);
} else {
await this.permissionDAO.update(oldConfirm.id, model);
}
}
if (userConfirm.allow) {
return Promise.resolve(true);
}
return Promise.reject(new Error("permission not allowed"));
}
// 确认map
confirmMap: Map<
string,
{
confirm: ConfirmParam;
script: Script;
resolve: (value: UserConfirm) => void;
reject: (reason: any) => void;
}
> = new Map();
// 弹出窗口让用户进行确认
async confirmWindow(
script: Script,
confirm: ConfirmParam
): Promise<UserConfirm> {
return new Promise((resolve, reject) => {
const uuid = uuidv4();
// 超时处理
const timeout = setTimeout(() => {
this.confirmMap.delete(uuid);
reject(new Error("permission confirm timeout"));
}, 40 * 1000);
// 保存到map中
this.confirmMap.set(uuid, {
confirm,
script,
resolve: (value: UserConfirm) => {
clearTimeout(timeout);
resolve(value);
},
reject,
});
// 打开窗口
chrome.tabs.create({
url: chrome.runtime.getURL(`src/confirm.html?uuid=${uuid}`),
});
});
}
}

View File

@ -1,735 +0,0 @@
// 脚本运行时,主要负责脚本的加载和匹配
// 油猴脚本将监听页面的创建,将代码注入到页面中
import MessageSandbox from "@App/app/message/sandbox";
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import {
Script,
SCRIPT_RUN_STATUS,
SCRIPT_STATUS_ENABLE,
SCRIPT_TYPE_NORMAL,
ScriptDAO,
ScriptRunResouce,
SCRIPT_RUN_STATUS_RUNNING,
Metadata,
} from "@App/app/repo/scripts";
import ResourceManager from "@App/app/service/resource/manager";
import ValueManager from "@App/app/service/value/manager";
import { dealScript, randomString } from "@App/pkg/utils/utils";
import { UrlInclude, UrlMatch } from "@App/pkg/utils/match";
import {
MessageHander,
MessageSender,
TargetTag,
} from "@App/app/message/message";
import ScriptManager from "@App/app/service/script/manager";
import { Channel } from "@App/app/message/channel";
import IoC from "@App/app/ioc";
import Manager from "@App/app/service/manager";
import Hook from "@App/app/service/hook";
import { i18nName } from "@App/locales/locales";
import { compileInjectScript, compileScriptCode } from "../content/utils";
import GMApi, { Request } from "./gm_api";
import { genScriptMenu } from "./utils";
export type RuntimeEvent = "start" | "stop" | "watchRunStatus";
export type ScriptMenuItem = {
id: number;
name: string;
accessKey?: string;
sender: MessageSender;
channelFlag: string;
};
export type ScriptMenu = {
id: number;
name: string;
enable: boolean;
updatetime: number;
hasUserConfig: boolean;
metadata: Metadata;
runStatus?: SCRIPT_RUN_STATUS;
runNum: number;
runNumByIframe: number;
menus?: ScriptMenuItem[];
customExclude?: string[];
};
// 后台脚本将会将代码注入到沙盒中
@IoC.Singleton(MessageHander, ResourceManager, ValueManager)
export default class Runtime extends Manager {
messageSandbox?: MessageSandbox;
scriptDAO: ScriptDAO;
resourceManager: ResourceManager;
valueManager: ValueManager;
logger: Logger;
match: UrlMatch<ScriptRunResouce> = new UrlMatch();
include: UrlInclude<ScriptRunResouce> = new UrlInclude();
// 自定义排除
customizeExclude: UrlMatch<ScriptRunResouce> = new UrlMatch();
static hook = new Hook<"runStatus">();
// 运行中和开启的后台脚本
runBackScript: Map<number, Script> = new Map();
constructor(
message: MessageHander,
resourceManager: ResourceManager,
valueManager: ValueManager
) {
super(message, "runtime");
this.scriptDAO = new ScriptDAO();
this.resourceManager = resourceManager;
this.valueManager = valueManager;
this.logger = LoggerCore.getInstance().logger({ component: "runtime" });
ScriptManager.hook.addListener("upsert", this.scriptUpdate.bind(this));
ScriptManager.hook.addListener("delete", this.scriptDelete.bind(this));
ScriptManager.hook.addListener("enable", this.scriptUpdate.bind(this));
ScriptManager.hook.addListener("disable", this.scriptUpdate.bind(this));
}
start(): void {
// 监听前端消息
// 此处是处理执行单次脚本的消息
this.listenEvent("start", (id) => {
return this.scriptDAO
.findById(id)
.then((script) => {
if (!script) {
throw new Error("script not found");
}
// 因为如果直接引用Runtime,会导致循环依赖,暂时这样处理,后面再梳理梳理
return this.startBackgroundScript(script);
})
.catch((e) => {
this.logger.error("run error", Logger.E(e));
throw e;
});
});
this.listenEvent("stop", (id) => {
return this.scriptDAO
.findById(id)
.then((script) => {
if (!script) {
throw new Error("script not found");
}
// 因为如果直接引用Runtime,会导致循环依赖,暂时这样处理
return this.stopBackgroundScript(id);
})
.catch((e) => {
this.logger.error("stop error", Logger.E(e));
throw e;
});
});
// 监听脚本运行状态
this.listenScriptRunStatus();
// 启动普通脚本
this.scriptDAO.table.toArray((items) => {
items.forEach((item) => {
// 容错处理
if (!item) {
this.logger.error("script is null");
return;
}
if (item.type !== SCRIPT_TYPE_NORMAL) {
return;
}
// 加载所有的脚本
if (item.status === SCRIPT_STATUS_ENABLE) {
this.enable(item);
} else {
// 只处理未开启的普通页面脚本
this.disable(item);
}
});
});
// 接受消息,注入脚本
// 获取注入源码
// 监听菜单创建
const scriptMenu: Map<
number | TargetTag,
Map<
number,
{
request: Request;
channel: Channel;
}[]
>
> = new Map();
GMApi.hook.addListener(
"registerMenu",
(request: Request, channel: Channel) => {
let senderId: number | TargetTag;
if (!request.sender.tabId) {
// 非页面脚本
senderId = request.sender.targetTag;
} else {
senderId = request.sender.tabId;
}
let tabMap = scriptMenu.get(senderId);
if (!tabMap) {
tabMap = new Map();
scriptMenu.set(senderId, tabMap);
}
let menuArr = tabMap.get(request.scriptId);
if (!menuArr) {
menuArr = [];
tabMap.set(request.scriptId, menuArr);
}
// 查询菜单是否已经存在
for (let i = 0; i < menuArr.length; i += 1) {
// id 相等 跳过,选第一个,并close链接
if (menuArr[i].request.params[0] === request.params[0]) {
channel.disChannel();
return;
}
}
menuArr.push({ request, channel });
// 偷懒行为, 直接重新生成菜单
genScriptMenu(senderId, scriptMenu);
}
);
GMApi.hook.addListener("unregisterMenu", (id, request: Request) => {
let senderId: number | TargetTag;
if (!request.sender.tabId) {
// 非页面脚本
senderId = request.sender.targetTag;
} else {
senderId = request.sender.tabId;
}
const tabMap = scriptMenu.get(senderId);
if (tabMap) {
const menuArr = tabMap.get(request.scriptId);
if (menuArr) {
// 从菜单数组中遍历删除
for (let i = 0; i < menuArr.length; i += 1) {
if (menuArr[i].request.params[0] === id) {
menuArr.splice(i, 1);
break;
}
}
if (menuArr.length === 0) {
tabMap.delete(request.scriptId);
}
}
if (!tabMap.size) {
scriptMenu.delete(senderId);
}
}
// 偷懒行为
genScriptMenu(senderId, scriptMenu);
});
// 监听页面切换加载菜单
chrome.tabs.onActivated.addListener((activeInfo) => {
genScriptMenu(activeInfo.tabId, scriptMenu);
});
Runtime.hook.addListener("runStatus", async (scriptId: number) => {
const script = await this.scriptDAO.findById(scriptId);
if (!script) {
return;
}
if (
script.status !== SCRIPT_STATUS_ENABLE &&
script.runStatus !== "running"
) {
// 没开启并且不是运行中的脚本,删除
this.runBackScript.delete(scriptId);
} else {
// 否则进行一次更新
this.runBackScript.set(scriptId, script);
}
});
// 记录运行次数与iframe运行
const runScript = new Map<
number,
Map<number, { script: Script; runNum: number; runNumByIframe: number }>
>();
const addRunScript = (
tabId: number,
script: Script,
iframe: boolean,
num: number = 1
) => {
let scripts = runScript.get(tabId);
if (!scripts) {
scripts = new Map();
runScript.set(tabId, scripts);
}
let scriptNum = scripts.get(script.id);
if (!scriptNum) {
scriptNum = { script, runNum: 0, runNumByIframe: 0 };
scripts.set(script.id, scriptNum);
}
if (script.status === SCRIPT_STATUS_ENABLE) {
scriptNum.runNum += num;
if (iframe) {
scriptNum.runNumByIframe += num;
}
}
};
chrome.tabs.onRemoved.addListener((tabId) => {
runScript.delete(tabId);
});
// 给popup页面获取运行脚本,与菜单
this.message.setHandler(
"queryPageScript",
async (action: string, { url, tabId }: any) => {
const tabMap = scriptMenu.get(tabId);
const run = runScript.get(tabId);
let matchScripts = [];
if (!run) {
matchScripts = this.matchUrl(url).map((item) => {
return { runNum: 0, runNumByIframe: 0, script: item };
});
} else {
matchScripts = Array.from(run.values());
}
const allPromise: Promise<ScriptMenu>[] = matchScripts.map(
async (item) => {
const menus: ScriptMenuItem[] = [];
if (tabMap) {
tabMap.get(item.script.id)?.forEach((scriptItem) => {
menus.push({
name: scriptItem.request.params[1],
accessKey: scriptItem.request.params[2],
id: scriptItem.request.params[0],
sender: scriptItem.request.sender,
channelFlag: scriptItem.channel.flag,
});
});
}
const script = await this.scriptDAO.findById(item.script.id);
if (!script) {
return {
id: item.script.id,
name: i18nName(item.script),
enable: item.script.status === SCRIPT_STATUS_ENABLE,
updatetime: item.script.updatetime || item.script.createtime,
metadata: item.script.metadata,
hasUserConfig: !!item.script.config,
runNum: item.runNum,
runNumByIframe: item.runNumByIframe,
customExclude:
item.script.selfMetadata && item.script.selfMetadata.exclude,
menus,
};
}
return {
id: script.id,
name: i18nName(script),
enable: script.status === SCRIPT_STATUS_ENABLE,
updatetime: script.updatetime || script.createtime,
metadata: item.script.metadata,
hasUserConfig: !!script?.config,
runNum: item.runNum,
runNumByIframe: item.runNumByIframe,
customExclude: script.selfMetadata && script.selfMetadata.exclude,
menus,
};
}
);
const scriptList: ScriptMenu[] = await Promise.all(allPromise);
const backScriptList: ScriptMenu[] = [];
const sandboxMenuMap = scriptMenu.get("sandbox");
this.runBackScript.forEach((item) => {
const menus: ScriptMenuItem[] = [];
if (sandboxMenuMap) {
sandboxMenuMap?.get(item.id)?.forEach((scriptItem) => {
menus.push({
name: scriptItem.request.params[1],
accessKey: scriptItem.request.params[2],
id: scriptItem.request.params[0],
sender: scriptItem.request.sender,
channelFlag: scriptItem.channel.flag,
});
});
}
backScriptList.push({
id: item.id,
name: item.name,
enable: item.status === SCRIPT_STATUS_ENABLE,
updatetime: item.updatetime || item.createtime,
metadata: item.metadata,
runStatus: item.runStatus,
hasUserConfig: !!item.config,
runNum:
item.runStatus && item.runStatus === SCRIPT_RUN_STATUS_RUNNING
? 1
: 0,
menus,
runNumByIframe: 0,
});
});
return Promise.resolve({
scriptList,
backScriptList,
});
}
);
// content页发送页面加载完成消息,注入脚本
this.message.setHandler(
"pageLoad",
(_action: string, data: any, sender: MessageSender) => {
return new Promise((resolve) => {
if (!sender) {
return;
}
if (!(sender.url && sender.tabId)) {
return;
}
if (sender.frameId === undefined) {
// 清理之前的数据
runScript.delete(sender.tabId);
}
// 未开启
if (localStorage.enable_script === "false") {
return;
}
const exclude = this.customizeExclude.match(sender.url);
// 自定义排除的, buildScriptRunResource时会将selfMetadata合并,所以后续不需要再处理metadata.exclude,这算是一个隐性的坑,后面看看要不要处理
exclude.forEach((val) => {
addRunScript(sender.tabId!, val, false, 0);
});
const filter: ScriptRunResouce[] = this.matchUrl(
sender.url,
(script) => {
// 如果是iframe,判断是否允许在iframe里运行
if (sender.frameId !== undefined) {
if (script.metadata.noframes) {
return true;
}
addRunScript(sender.tabId!, script, true);
return script.status !== SCRIPT_STATUS_ENABLE;
}
addRunScript(sender.tabId!, script, false);
return script.status !== SCRIPT_STATUS_ENABLE;
}
);
if (!filter.length) {
resolve({ scripts: [] });
return;
}
resolve({ scripts: filter });
// 注入脚本
filter.forEach((script) => {
let runAt = "document_idle";
if (script.metadata["run-at"]) {
[runAt] = script.metadata["run-at"];
}
switch (runAt) {
case "document-body":
case "document-start":
runAt = "document_start";
break;
case "document-end":
runAt = "document_end";
break;
case "document-idle":
default:
runAt = "document_idle";
break;
}
chrome.tabs.executeScript(sender.tabId!, {
frameId: sender.frameId,
code: `(function(){
let temp = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
temp.setAttribute('type', 'text/javascript');
temp.innerHTML = "${script.code}";
temp.className = "injected-js";
document.documentElement.appendChild(temp);
temp.remove();
}())`,
runAt,
});
});
// 角标和脚本
chrome.browserAction.getBadgeText(
{
tabId: sender.tabId,
},
(res: string) => {
chrome.browserAction.setBadgeText({
text: (filter.length + (parseInt(res, 10) || 0)).toString(),
tabId: sender.tabId,
});
}
);
chrome.browserAction.setBadgeBackgroundColor({
color: "#4e5969",
tabId: sender.tabId,
});
});
}
);
}
setMessageSandbox(messageSandbox: MessageSandbox) {
this.messageSandbox = messageSandbox;
}
// 启动沙盒相关脚本
startSandbox(messageSandbox: MessageSandbox) {
this.messageSandbox = messageSandbox;
this.scriptDAO.table.toArray((items) => {
items.forEach((item) => {
// 容错处理
if (!item) {
this.logger.error("script is null");
return;
}
if (item.type === SCRIPT_TYPE_NORMAL) {
return;
}
// 加载所有的脚本
if (item.status === SCRIPT_STATUS_ENABLE) {
this.enable(item);
this.runBackScript.set(item.id, item);
}
});
});
}
listenScriptRunStatus() {
// 监听沙盒发送的脚本运行状态消息
this.message.setHandler(
"scriptRunStatus",
(action, [scriptId, runStatus, error, nextruntime]: any) => {
this.scriptDAO.update(scriptId, {
runStatus,
lastruntime: new Date().getTime(),
nextruntime,
error,
});
Runtime.hook.trigger("runStatus", scriptId, runStatus);
}
);
// 处理前台发送的脚本运行状态监听请求
this.message.setHandlerWithChannel("watchRunStatus", (channel) => {
const hook = (scriptId: number, status: SCRIPT_RUN_STATUS) => {
channel.send([scriptId, status]);
};
Runtime.hook.addListener("runStatus", hook);
channel.setDisChannelHandler(() => {
Runtime.hook.removeListener("runStatus", hook);
});
});
}
// 脚本发生变动
async scriptUpdate(script: Script): Promise<boolean> {
// 脚本更新先更新资源
await this.resourceManager.checkScriptResource(script);
if (script.status === SCRIPT_STATUS_ENABLE) {
return this.enable(script as ScriptRunResouce);
}
return this.disable(script);
}
matchUrl(url: string, filterFunc?: (script: Script) => boolean) {
const scripts = this.match.match(url);
// 再include中匹配
scripts.push(...this.include.match(url));
const filter: { [key: string]: ScriptRunResouce } = {};
// 去重
scripts.forEach((script) => {
if (filterFunc && filterFunc(script)) {
return;
}
filter[script.id] = script;
});
// 转换成数组
return Object.keys(filter).map((key) => filter[key]);
}
// 脚本删除
async scriptDelete(script: Script): Promise<boolean> {
// 清理匹配资源
if (script.type === SCRIPT_TYPE_NORMAL) {
this.match.del(<ScriptRunResouce>script);
this.include.del(<ScriptRunResouce>script);
} else {
this.unloadBackgroundScript(script);
}
return Promise.resolve(true);
}
// 脚本开启
async enable(script: Script): Promise<boolean> {
// 编译脚本运行资源
const scriptRes = await this.buildScriptRunResource(script);
if (script.type !== SCRIPT_TYPE_NORMAL) {
return this.loadBackgroundScript(scriptRes);
}
return this.loadPageScript(scriptRes);
}
// 脚本关闭
disable(script: Script): Promise<boolean> {
if (script.type !== SCRIPT_TYPE_NORMAL) {
return this.unloadBackgroundScript(script);
}
return this.unloadPageScript(script);
}
// 加载页面脚本
loadPageScript(script: ScriptRunResouce) {
// 重构code
const logger = this.logger.with({
scriptId: script.id,
name: script.name,
});
script.code = dealScript(compileInjectScript(script));
this.match.del(<ScriptRunResouce>script);
this.include.del(<ScriptRunResouce>script);
if (script.metadata.match) {
script.metadata.match.forEach((url) => {
try {
this.match.add(url, script);
} catch (e) {
logger.error("url load error", Logger.E(e));
}
});
}
if (script.metadata.include) {
script.metadata.include.forEach((url) => {
try {
this.include.add(url, script);
} catch (e) {
logger.error("url load error", Logger.E(e));
}
});
}
if (script.metadata.exclude) {
script.metadata.exclude.forEach((url) => {
try {
this.include.exclude(url, script);
this.match.exclude(url, script);
} catch (e) {
logger.error("url load error", Logger.E(e));
}
});
}
if (script.selfMetadata && script.selfMetadata.exclude) {
script.selfMetadata.exclude.forEach((url) => {
try {
this.customizeExclude.add(url, script);
} catch (e) {
logger.error("url load error", Logger.E(e));
}
});
}
return Promise.resolve(true);
}
// 卸载页面脚本
unloadPageScript(script: Script) {
return this.loadPageScript(<ScriptRunResouce>script);
}
// 加载并启动后台脚本
loadBackgroundScript(script: ScriptRunResouce): Promise<boolean> {
this.runBackScript.set(script.id, script);
return new Promise((resolve, reject) => {
// 清除重试数据
script.nextruntime = 0;
this.messageSandbox
?.syncSend("enable", script)
.then(() => {
resolve(true);
})
.catch((err) => {
this.logger.error("backscript load error", Logger.E(err));
reject(err);
});
});
}
// 卸载并停止后台脚本
unloadBackgroundScript(script: Script): Promise<boolean> {
this.runBackScript.delete(script.id);
return new Promise((resolve, reject) => {
this.messageSandbox
?.syncSend("disable", script.id)
.then(() => {
resolve(true);
})
.catch((err) => {
this.logger.error("backscript stop error", Logger.E(err));
reject(err);
});
});
}
async startBackgroundScript(script: Script) {
const scriptRes = await this.buildScriptRunResource(script);
this.messageSandbox?.syncSend("start", scriptRes);
return Promise.resolve(true);
}
stopBackgroundScript(scriptId: number) {
return new Promise((resolve, reject) => {
this.messageSandbox
?.syncSend("stop", scriptId)
.then((resp) => {
resolve(resp);
})
.catch((err) => {
this.logger.error("backscript stop error", Logger.E(err));
reject(err);
});
});
}
async buildScriptRunResource(script: Script): Promise<ScriptRunResouce> {
const ret: ScriptRunResouce = <ScriptRunResouce>Object.assign(script);
// 自定义配置
if (ret.selfMetadata) {
ret.metadata = { ...ret.metadata };
Object.keys(ret.selfMetadata).forEach((key) => {
ret.metadata[key] = ret.selfMetadata![key];
});
}
ret.value = await this.valueManager.getScriptValues(ret);
ret.resource = await this.resourceManager.getScriptResources(ret);
ret.flag = randomString(16);
ret.sourceCode = ret.code;
ret.code = compileScriptCode(ret);
ret.grantMap = {};
ret.metadata.grant?.forEach((val: string) => {
ret.grantMap[val] = "ok";
});
return Promise.resolve(ret);
}
}

View File

@ -1,535 +0,0 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { Channel } from "@App/app/message/channel";
import { SCRIPT_STATUS_ENABLE, Script } from "@App/app/repo/scripts";
import { isFirefox } from "@App/pkg/utils/utils";
import MessageCenter from "@App/app/message/center";
import IoC from "@App/app/ioc";
import { Request } from "./gm_api";
import Runtime from "./runtime";
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 const responseHeaders: { [key: string]: boolean } = {
"set-cookie": true,
};
export function isUnsafeHeaders(header: string) {
return unsafeHeaders[header.toLocaleLowerCase()];
}
export function isExtensionRequest(
details: chrome.webRequest.ResourceRequest & { originUrl?: string }
): boolean {
return !!(
(details.initiator &&
chrome.runtime.getURL("").startsWith(details.initiator)) ||
(details.originUrl &&
details.originUrl.startsWith(chrome.runtime.getURL("")))
);
}
// 监听web请求,处理unsafeHeaders
export function listenerWebRequest(headerFlag: string) {
const reqOpt = ["blocking", "requestHeaders"];
const respOpt = ["blocking", "responseHeaders"];
if (!isFirefox()) {
reqOpt.push("extraHeaders");
respOpt.push("extraHeaders");
}
const maxRedirects = new Map<string, [number, number]>();
const isRedirects = new Map<string, boolean>();
// 处理发送请求的unsafeHeaders
chrome.webRequest.onBeforeSendHeaders.addListener(
(details) => {
if (!isExtensionRequest(details)) {
return {};
}
// 处理unsafeHeaders
let cookie = "";
let setCookie = "";
let anonymous = false;
let isGmXhr = false;
const requestHeaders: chrome.webRequest.HttpHeader[] = [];
const preRequestHeaders: { [key: string]: string | null } = {};
details.requestHeaders?.forEach((val) => {
const lowerCase = val.name.toLowerCase();
if (lowerCase.startsWith(`${headerFlag}-`)) {
const headerKey = lowerCase.substring(headerFlag.length + 1);
// 处理unsafeHeaders
switch (headerKey) {
case "cookie":
setCookie = val.value || "";
break;
case "max-redirects":
maxRedirects.set(details.requestId, [
0,
parseInt(val.value || "", 10),
]);
break;
case "anonymous":
anonymous = true;
break;
case "gm-xhr":
isGmXhr = true;
break;
default:
preRequestHeaders[headerKey] = val.value || null;
break;
}
return;
}
// 原生header
switch (lowerCase) {
case "cookie":
cookie = val.value || "";
break;
default:
// 如果是unsafeHeaders,则判断是否已经有值,有值则不进行处理
if (
unsafeHeaders[lowerCase] ||
lowerCase.startsWith("sec-") ||
lowerCase.startsWith("proxy-")
) {
// null表示不发送此header
if (preRequestHeaders[lowerCase] !== null) {
preRequestHeaders[lowerCase] =
preRequestHeaders[lowerCase] || val.value || "";
}
} else {
requestHeaders.push(val);
}
break;
}
});
// 不是由GM XHR发起的请求,不处理
if (!isGmXhr) {
return {};
}
// 匿名移除掉cookie
if (anonymous) {
cookie = "";
}
// 有设置cookie,则进行处理
if (setCookie) {
// 判断结尾是否有分号,没有则添加,然后进行拼接
if (!cookie || cookie.endsWith(";")) {
cookie += setCookie;
} else {
cookie += `;${setCookie}`;
}
}
// 有cookie,则进行处理
if (cookie) {
requestHeaders.push({
name: "Cookie",
value: cookie,
});
}
Object.keys(preRequestHeaders).forEach((key) => {
// null表示不发送此header
if (preRequestHeaders[key] !== null) {
requestHeaders.push({
name: key,
value: preRequestHeaders[key]!,
});
}
});
return {
requestHeaders,
};
},
{
urls: ["<all_urls>"],
},
reqOpt
);
// 处理无法读取的responseHeaders
chrome.webRequest.onHeadersReceived.addListener(
(details) => {
if (!isExtensionRequest(details)) {
// 判断是否为页面请求
if (
!(details.type === "main_frame" || details.type === "sub_frame") ||
!isFirefox()
) {
return {};
}
// 判断页面上是否有脚本会运行,如果有判断是否有csp,有则移除csp策略
const runtime = IoC.instance(Runtime) as Runtime;
// 这块代码与runtime里的pageLoad一样,考虑后面要不要优化
const result = runtime.matchUrl(details.url, (script) => {
// 如果是iframe,判断是否允许在iframe里运行
if (details.type === "sub_frame") {
if (script.metadata.noframes) {
return true;
}
return script.status !== SCRIPT_STATUS_ENABLE;
}
return script.status !== SCRIPT_STATUS_ENABLE;
});
if (result.length > 0 && details.responseHeaders) {
// 移除csp
for (let i = 0; i < details.responseHeaders.length; i += 1) {
if (
details.responseHeaders[i].name.toLowerCase() ===
"content-security-policy"
) {
details.responseHeaders[i].value = "";
}
}
return {
responseHeaders: details.responseHeaders,
};
}
return {};
}
const appendHeaders: chrome.webRequest.HttpHeader[] = [];
details.responseHeaders?.forEach((val) => {
const lowerCase = val.name.toLowerCase();
if (responseHeaders[lowerCase]) {
const copy = { ...val };
copy.name = `${headerFlag}-${val.name}`;
appendHeaders.push(copy);
}
// 处理最大重定向次数
if (lowerCase === "location") {
isRedirects.set(details.requestId, true);
const nums = maxRedirects.get(details.requestId);
if (nums) {
nums[0] += 1;
// 当前重定向次数大于最大重定向次数时,修改掉locatin,防止重定向
if (nums[0] > nums[1]) {
val.name = `${headerFlag}-${val.name}`;
}
}
}
});
details.responseHeaders?.push(...appendHeaders);
// 判断是否为重定向请求,如果是,将url注入到finalUrl
if (isRedirects.has(details.requestId)) {
details.responseHeaders?.push({
name: `${headerFlag}-final-url`,
value: details.url,
});
}
return {
responseHeaders: details.responseHeaders,
};
},
{
urls: ["<all_urls>"],
},
respOpt
);
chrome.webRequest.onCompleted.addListener(
(details) => {
if (!isExtensionRequest(details)) {
return;
}
// 删除最大重定向数缓存
maxRedirects.delete(details.requestId);
isRedirects.delete(details.requestId);
},
{ urls: ["<all_urls>"] }
);
}
// 给xhr添加headers,包括unsafeHeaders
export function setXhrHeader(
headerFlag: string,
config: GMSend.XHRDetails,
xhr: XMLHttpRequest
) {
xhr.setRequestHeader(`${headerFlag}-gm-xhr`, "true");
if (config.headers) {
let hasOrigin = false;
Object.keys(config.headers).forEach((key) => {
const lowKey = key.toLowerCase();
if (lowKey === "origin") {
hasOrigin = true;
}
try {
if (
unsafeHeaders[lowKey] ||
lowKey.startsWith("sec-") ||
lowKey.startsWith("proxy-")
) {
xhr.setRequestHeader(
`${headerFlag}-${lowKey}`,
config.headers![key]!
);
} else {
// 直接设置header
xhr.setRequestHeader(key, config.headers![key]!);
}
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error(
"GM XHR setRequestHeader error"
);
}
});
if (!hasOrigin) {
xhr.setRequestHeader(`${headerFlag}-origin`, "");
}
}
if (config.maxRedirects !== undefined) {
xhr.setRequestHeader(
`${headerFlag}-max-redirects`,
config.maxRedirects.toString()
);
}
if (config.cookie) {
try {
xhr.setRequestHeader(`${headerFlag}-cookie`, config.cookie);
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error(
"GM XHR setRequestHeader cookie error"
);
}
}
if (config.anonymous) {
xhr.setRequestHeader(`${headerFlag}-anonymous`, "true");
}
}
export function getFetchHeader(
headerFlag: string,
config: GMSend.XHRDetails
): any {
const headers: { [key: string]: string } = {};
headers[`${headerFlag}-gm-xhr`] = "true";
if (config.headers) {
Object.keys(config.headers).forEach((key) => {
const lowKey = key.toLowerCase();
if (
unsafeHeaders[lowKey] ||
lowKey.startsWith("sec-") ||
lowKey.startsWith("proxy-")
) {
headers[`${headerFlag}-${lowKey}`] = config.headers![key]!;
} else {
// 直接设置header
headers[key] = config.headers![key]!;
}
});
}
if (config.maxRedirects !== undefined) {
headers[`${headerFlag}-max-redirects`] = config.maxRedirects.toString();
}
if (config.cookie) {
headers[`${headerFlag}-cookie`] = config.cookie;
}
if (config.anonymous) {
headers[`${headerFlag}-anonymous`] = "true";
}
return headers;
}
export async function dealXhr(
headerFlag: string,
config: GMSend.XHRDetails,
xhr: XMLHttpRequest
): Promise<GMTypes.XHRResponse> {
let finalUrl = xhr.responseURL || config.url;
// 判断是否有headerFlag-final-url,有则替换finalUrl
const finalUrlHeader = xhr.getResponseHeader(`${headerFlag}-final-url`);
if (finalUrlHeader) {
finalUrl = finalUrlHeader;
}
const removeXCat = new RegExp(`${headerFlag}-`, "g");
const respond: GMTypes.XHRResponse = {
finalUrl,
readyState: <any>xhr.readyState,
status: xhr.status,
statusText: xhr.statusText,
responseHeaders: xhr.getAllResponseHeaders().replace(removeXCat, ""),
responseType: config.responseType,
};
if (xhr.readyState === 4) {
if (
config.responseType?.toLowerCase() === "arraybuffer" ||
config.responseType?.toLowerCase() === "blob"
) {
let blob: Blob;
if (xhr.response instanceof ArrayBuffer) {
blob = new Blob([xhr.response]);
respond.response = URL.createObjectURL(blob);
} else {
blob = <Blob>xhr.response;
respond.response = URL.createObjectURL(blob);
}
try {
if (xhr.getResponseHeader("Content-Type")?.indexOf("text") !== -1) {
// 如果是文本类型,则尝试转换为文本
respond.responseText = await blob.text();
}
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error(
"GM XHR getResponseHeader error"
);
}
setTimeout(() => {
URL.revokeObjectURL(<string>respond.response);
}, 60e3);
} else if (config.responseType === "json") {
try {
respond.response = JSON.parse(xhr.responseText);
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error("GM XHR JSON parse error");
}
try {
respond.responseText = xhr.responseText;
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error("GM XHR getResponseText error");
}
} else {
try {
respond.response = xhr.response;
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error("GM XHR response error");
}
try {
respond.responseText = xhr.responseText || undefined;
} catch (e) {
LoggerCore.getLogger(Logger.E(e)).error("GM XHR getResponseText error");
}
}
}
return Promise.resolve(respond);
}
export function dealFetch(
headerFlag: string,
config: GMSend.XHRDetails,
response: Response,
readyState: 0 | 1 | 2 | 3 | 4
) {
const removeXCat = new RegExp(`${headerFlag}-`, "g");
let respHeader = "";
response.headers &&
response.headers.forEach((value, key) => {
respHeader += `${key.replace(removeXCat, "")}: ${value}\n`;
});
const respond: GMTypes.XHRResponse = {
finalUrl: response.url || config.url,
readyState,
status: response.status,
statusText: response.statusText,
responseHeaders: respHeader,
responseType: config.responseType,
};
return respond;
}
export function getIcon(script: Script): string {
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])
);
}
function genScriptMenuByTabMap(
tabMap: Map<number, { request: Request; channel: Channel }[]>
) {
tabMap.forEach((menuArr, scriptId) => {
// 创建脚本菜单
chrome.contextMenus.create({
id: `scriptMenu_${scriptId}`,
title: menuArr[0].request.script.name,
contexts: ["all"],
parentId: "scriptMenu",
});
menuArr.forEach((menu) => {
// 创建菜单
chrome.contextMenus.create({
id: `scriptMenu_menu_${scriptId}_${menu.request.params[0]}`,
title: menu.request.params[1],
contexts: ["all"],
parentId: `scriptMenu_${scriptId}`,
onclick: () => {
(IoC.instance(MessageCenter) as MessageCenter).sendNative(
{
tag: menu.request.sender.targetTag,
id: [
menu.request.sender.frameId || menu.request.sender.tabId || 0,
],
},
{
stream: menu.channel.flag,
channel: true,
data: "click",
}
);
},
});
});
});
}
// 生成chrome菜单
export function genScriptMenu(
tabId: number | string,
scriptMenu: Map<
number | string,
Map<
number,
{
request: Request;
channel: Channel;
}[]
>
>
) {
// 移除之前所有的菜单
chrome.contextMenus.removeAll();
const tabMap = scriptMenu.get(tabId);
const backTabMap = scriptMenu.get("sandbox");
if (!tabMap && !backTabMap) {
return;
}
// 创建根菜单
chrome.contextMenus.create({
id: "scriptMenu",
title: "ScriptCat",
contexts: ["all"],
});
if (tabMap) {
genScriptMenuByTabMap(tabMap);
}
// 后台脚本的菜单
if (tabId !== "sandbox") {
if (backTabMap) {
genScriptMenuByTabMap(backTabMap);
}
}
}

View File

@ -8,8 +8,14 @@ import { compileScript, createContext, proxyContext, ScriptFunc } from "./utils"
export type ValueUpdateData = {
oldValue: any;
value: Value;
sender: {
runFlag: string;
tabId?: number;
};
};
export class RuntimeMessage {}
// 执行脚本,控制脚本执行与停止
export default class ExecScript {
scriptRes: ScriptRunResouce;
@ -24,27 +30,26 @@ export default class ExecScript {
GM_info: any;
constructor(scriptRes: ScriptRunResouce, scriptFunc?: ScriptFunc, thisContext?: { [key: string]: any }) {
constructor(scriptRes: ScriptRunResouce, thisContext?: { [key: string]: any }) {
this.scriptRes = scriptRes;
this.logger = LoggerCore.getInstance().logger({
component: "exec",
uuid: this.scriptRes.uuid,
script: this.scriptRes.uuid,
name: this.scriptRes.name,
});
this.GM_info = GMApi.GM_info(this.scriptRes);
this.proxyMessage = new ProxyMessageManager(message);
if (scriptFunc) {
this.scriptFunc = scriptFunc;
} else {
// 构建脚本资源
this.scriptFunc = compileScript(this.scriptRes.code);
}
if (scriptRes.grantMap.none) {
// 构建脚本资源
this.scriptFunc = compileScript(this.scriptRes.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, this.proxyMessage);
this.sandboxContent = createContext(scriptRes, this.GM_info);
this.proxyContent = proxyContext(global, this.sandboxContent, thisContext);
}
}
@ -62,7 +67,6 @@ export default class ExecScript {
// TODO: 实现脚本的停止,资源释放
stop() {
this.logger.debug("script stop");
this.proxyMessage.cleanChannel();
return true;
}
}

View File

@ -1,6 +1,3 @@
/* eslint-disable func-names */
/* eslint-disable max-classes-per-file */
import { MessageManager } from "@App/app/message/message";
import { ScriptRunResouce } from "@App/app/repo/scripts";
import ExecScript from "./exec_script";
@ -24,15 +21,11 @@ export class BgExecScriptWarp extends ExecScript {
setInterval: Map<number, boolean>;
constructor(scriptRes: ScriptRunResouce, message: MessageManager) {
constructor(scriptRes: ScriptRunResouce) {
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
) {
thisContext.setTimeout = function (handler: () => void, timeout: number | undefined, ...args: any) {
const t = global.setTimeout(
function () {
setTimeout.delete(t);
@ -50,11 +43,7 @@ export class BgExecScriptWarp extends ExecScript {
setTimeout.delete(t);
global.clearTimeout(t);
};
thisContext.setInterval = function (
handler: () => void,
timeout: number | undefined,
...args: any
) {
thisContext.setInterval = function (handler: () => void, timeout: number | undefined, ...args: any) {
const t = global.setInterval(
function () {
if (typeof handler === "function") {
@ -73,7 +62,7 @@ export class BgExecScriptWarp extends ExecScript {
};
// @ts-ignore
thisContext.CATRetryError = CATRetryError;
super(scriptRes, message, undefined, thisContext);
super(scriptRes, thisContext);
this.setTimeout = setTimeout;
this.setInterval = setInterval;
}

View File

@ -1,20 +1,8 @@
/* eslint-disable camelcase */
/* eslint-disable max-classes-per-file */
import { ExtVersion } from "@App/app/const";
import LoggerCore from "@App/app/logger/core";
import { Channel, ChannelHandler } from "@App/app/message/channel";
import MessageContent from "@App/app/message/content";
import { MessageManager } from "@App/app/message/message";
import { ScriptRunResouce } from "@App/app/repo/scripts";
import {
base64ToBlob,
blobToBase64,
getMetadataStr,
getUserConfigStr,
parseUserConfig,
} from "@App/pkg/utils/script";
import { getMetadataStr, getUserConfigStr, parseUserConfig } from "@App/pkg/utils/script";
import { v4 as uuidv4 } from "uuid";
import { ValueUpdateData } from "./exec_script";
import { ExtVersion } from "@App/app/const";
interface ApiParam {
depend?: string[];
@ -30,11 +18,7 @@ export class GMContext {
static apis: Map<string, ApiValue> = new Map();
public static API(param: ApiParam = {}) {
return (
target: any,
propertyName: string,
descriptor: PropertyDescriptor
) => {
return (target: any, propertyName: string, descriptor: PropertyDescriptor) => {
const key = propertyName;
if (param.listener) {
param.listener();
@ -69,46 +53,25 @@ export class GMContext {
export default class GMApi {
scriptRes!: ScriptRunResouce;
message!: MessageManager;
runFlag!: string;
valueChangeListener = new Map<
number,
{ name: string; listener: GMTypes.ValueChangeListener }
>();
valueChangeListener = new Map<number, { name: string; listener: GMTypes.ValueChangeListener }>();
// 单次回调使用
public sendMessage(api: string, params: any[]) {
return this.message.syncSend("gmApi", {
api,
scriptId: this.scriptRes.id,
params,
runFlag: this.runFlag,
});
return null;
}
// 长连接使用,connect只用于接受消息,不能发送消息
public connect(api: string, params: any[], handler: ChannelHandler): Channel {
const uuid = uuidv4();
const channel = this.message.channel(uuid);
channel.setHandler(handler);
channel.channel("gmApiChannel", {
api,
scriptId: this.scriptRes.id,
params,
runFlag: this.runFlag,
});
return channel;
public connect(api: string, params: any[]) {
return null;
}
public valueUpdate(data: ValueUpdateData) {
const { storagename } = this.scriptRes.metadata;
if (
data.value.scriptId === this.scriptRes.id ||
(storagename &&
data.value.storageName &&
storagename[0] === data.value.storageName)
data.value.uuid === this.scriptRes.uuid ||
(storagename && data.value.storageName && storagename[0] === data.value.storageName)
) {
// 触发,并更新值
if (data.value.value === undefined) {
@ -132,16 +95,13 @@ export default class GMApi {
// 获取脚本信息和管理器信息
static GM_info(script: ScriptRunResouce) {
const metadataStr = getMetadataStr(script.sourceCode);
const userConfigStr = getUserConfigStr(script.sourceCode) || "";
const metadataStr = getMetadataStr(script.code);
const userConfigStr = getUserConfigStr(script.code) || "";
const options = {
description:
(script.metadata.description && script.metadata.description[0]) || null,
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",
"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,
@ -170,744 +130,4 @@ export default class GMApi {
},
};
}
// 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间
@GMContext.API()
public GM_getValue(key: string, defaultValue?: any) {
const ret = this.scriptRes.value[key];
if (ret) {
return ret.value;
}
return defaultValue;
}
@GMContext.API()
public GM_setValue(key: string, value: any) {
// 对object的value进行一次转化
if (typeof value === "object") {
value = JSON.parse(JSON.stringify(value));
}
let ret = this.scriptRes.value[key];
if (ret) {
ret.value = value;
} else {
ret = {
id: 0,
scriptId: this.scriptRes.id,
storageName:
(this.scriptRes.metadata.storagename &&
this.scriptRes.metadata.storagename[0]) ||
"",
key,
value,
createtime: new Date().getTime(),
updatetime: 0,
};
}
if (value === undefined) {
delete this.scriptRes.value[key];
} else {
this.scriptRes.value[key] = ret;
}
return this.sendMessage("GM_setValue", [key, value]);
}
@GMContext.API({ depend: ["GM_setValue"] })
public GM_deleteValue(name: string): void {
this.GM_setValue(name, undefined);
}
@GMContext.API()
public GM_listValues(): string[] {
return Object.keys(this.scriptRes.value);
}
@GMContext.API()
public GM_addValueChangeListener(
name: string,
listener: GMTypes.ValueChangeListener
): number {
const id = Math.random() * 10000000;
this.valueChangeListener.set(id, { name, listener });
return id;
}
@GMContext.API()
public GM_removeValueChangeListener(listenerId: number): void {
this.valueChangeListener.delete(listenerId);
}
// 辅助GM_xml获取blob数据
@GMContext.API()
public CAT_fetchBlob(url: string): Promise<Blob> {
return this.message.syncSend("CAT_fetchBlob", url);
}
@GMContext.API()
public CAT_fetchDocument(url: string): Promise<Document | undefined> {
return new Promise((resolve) => {
let el: Document | undefined;
(<MessageContent>this.message).sendCallback(
"CAT_fetchDocument",
url,
(resp) => {
el = <Document>(
(<unknown>(
(<MessageContent>this.message).getAndDelRelatedTarget(
resp.relatedTarget
)
))
);
resolve(el);
}
);
});
}
// 辅助GM_xml发送blob数据
@GMContext.API()
public CAT_createBlobUrl(blob: Blob): Promise<string> {
return this.message.syncSend("CAT_createBlobUrl", blob);
}
// 用于脚本跨域请求,需要@connect domain指定允许的域名
@GMContext.API({
depend: [
"CAT_fetchBlob",
"CAT_createBlobUrl",
"CAT_fetchDocument",
"GM_xmlhttpRequest",
],
})
GMdotXmlHttpRequest(details: GMTypes.XHRDetails) {
let abort: any;
const ret = new Promise((resolve, reject) => {
const oldOnload = details.onload;
details.onload = (data) => {
resolve(data);
oldOnload && oldOnload(data);
};
const oldOnerror = details.onerror;
details.onerror = (data) => {
reject(data);
oldOnerror && oldOnerror(data);
};
// @ts-ignore
abort = this.GM_xmlhttpRequest(details);
});
if (abort && abort.abort) {
// @ts-ignore
ret.abort = abort.abort;
}
return ret;
}
// 用于脚本跨域请求,需要@connect domain指定允许的域名
@GMContext.API({
depend: ["CAT_fetchBlob", "CAT_createBlobUrl", "CAT_fetchDocument"],
})
public GM_xmlhttpRequest(details: GMTypes.XHRDetails) {
let connect: Channel;
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";
}
const handler = async () => {
if (details.data) {
if (details.data instanceof FormData) {
param.dataType = "FormData";
const data: Array<GMSend.XHRFormData> = [];
const keys: { [key: string]: boolean } = {};
details.data.forEach((val, key) => {
keys[key] = true;
});
const asyncArr = Object.keys(keys).map((key) => {
const values = (<FormData>details.data).getAll(key);
const asyncArr2 = values.map((val) => {
return new Promise<void>((resolve) => {
if (val instanceof File) {
blobToBase64(val).then((base64) => {
data.push({
key,
type: "file",
val: base64 || "",
filename: val.name,
});
resolve();
});
} else {
data.push({
key,
type: "text",
val,
});
resolve();
}
});
});
return Promise.all(asyncArr2);
});
await Promise.all(asyncArr);
param.data = data;
} else if (details.data instanceof Blob) {
param.dataType = "Blob";
param.data = await this.CAT_createBlobUrl(details.data);
} else {
param.data = details.data;
}
}
let readerStream: ReadableStream<Uint8Array> | undefined;
// eslint-disable-next-line no-undef
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
// 如果返回类型是arraybuffer或者blob的情况下,需要将返回的数据转化为blob
// 在background通过URL.createObjectURL转化为url,然后在content页读取url获取blob对象
const responseType = details.responseType?.toLocaleLowerCase();
const warpResponse = (old: Function) => {
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);
}
}
}
connect = this.connect("GM_xmlhttpRequest", [param], (resp: any) => {
const data = <GMTypes.XHRResponse>resp.data || {};
switch (resp.event) {
case "onload":
details.onload && details.onload(data);
break;
case "onloadend":
details.onloadend && details.onloadend(data);
if (readerStream) {
controller?.close();
}
break;
case "onloadstart":
details.onloadstart && details.onloadstart(data);
break;
case "onprogress":
details.onprogress && details.onprogress(<GMTypes.XHRProgress>data);
break;
case "onreadystatechange":
details.onreadystatechange && details.onreadystatechange(data);
break;
case "ontimeout":
details.ontimeout && details.ontimeout();
break;
case "onerror":
details.onerror && details.onerror("");
break;
case "onabort":
details.onabort && details.onabort();
break;
case "onstream":
controller?.enqueue(new Uint8Array(resp.data));
break;
default:
LoggerCore.getLogger().warn("GM_xmlhttpRequest resp is error", {
resp,
});
break;
}
});
connect.setCatch((err) => {
details.onerror && details.onerror(err);
});
};
handler();
return {
abort: () => {
if (connect) {
connect.disChannel();
}
},
};
}
@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;
// eslint-disable-next-line no-fallthrough
case 3:
data.image = image;
// eslint-disable-next-line no-fallthrough
case 2:
data.title = <string>ondone;
// eslint-disable-next-line no-fallthrough
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.connect("GM_notification", [data], (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.getLogger().warn("GM_notification resp is error", {
resp,
});
break;
}
});
}
@GMContext.API()
public GM_closeNotification(id: string) {
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_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({ 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: () => {
this.GM_closeInTab(tabid);
},
};
const connect = this.connect("GM_openInTab", [url, option], (data) => {
switch (data.event) {
case "oncreate":
tabid = data.tabId;
break;
case "onclose":
ret.onclose && ret.onclose();
ret.closed = true;
connect.disChannel();
break;
default:
break;
}
});
return ret;
}
@GMContext.API()
public GM_closeInTab(tabid: string) {
return this.sendMessage("GM_closeInTab", [tabid]);
}
@GMContext.API()
GM_getResourceText(name: string): string | undefined {
if (!this.scriptRes.resource) {
return undefined;
}
const r = this.scriptRes.resource[name];
if (r) {
return r.content;
}
return undefined;
}
@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;
}
@GMContext.API()
GM_addStyle(css: string) {
let el: Element | undefined;
// 与content页的消息通讯实际是同步,此方法不需要经过background
// 所以可以直接在then中赋值el再返回
(<MessageContent>this.message).sendCallback(
"GM_addElement",
{
param: [
"style",
{
textContent: css,
},
],
},
(resp) => {
el = (<MessageContent>this.message).getAndDelRelatedTarget(
resp.relatedTarget
);
}
);
return el;
}
@GMContext.API()
async GM_getTab(callback: (data: any) => void) {
const resp = await this.sendMessage("GM_getTab", []);
callback(resp);
}
@GMContext.API()
GM_saveTab(obj: object) {
if (typeof obj === "object") {
obj = JSON.parse(JSON.stringify(obj));
}
return this.sendMessage("GM_saveTab", [obj]);
}
@GMContext.API()
async GM_getTabs(
callback: (objs: { [key: string | number]: object }) => any
) {
const resp = await this.sendMessage("GM_getTabs", []);
callback(resp);
}
@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;
}
const connect = 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,
},
],
(resp: any) => {
const data = <GMTypes.XHRResponse>resp.data || {};
switch (resp.event) {
case "onload":
details.onload && details.onload(data);
break;
case "onprogress":
details.onprogress && details.onprogress(<GMTypes.XHRProgress>data);
break;
case "ontimeout":
details.ontimeout && details.ontimeout();
break;
case "onerror":
details.onerror &&
details.onerror({
error: "unknown",
});
break;
default:
LoggerCore.getLogger().warn("GM_download resp is error", {
resp,
});
break;
}
}
);
return {
abort: () => {
connect.disChannel();
},
};
}
@GMContext.API()
GM_setClipboard(
data: string,
info?: string | { type?: string; minetype?: string }
) {
return this.sendMessage("GM_setClipboard", [data, info]);
}
@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);
});
}
menuId: number | undefined;
menuMap: Map<number, string> | undefined;
@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, key) => {
if (val === name) {
flag = key;
}
});
if (flag) {
return flag;
}
if (!this.menuId) {
this.menuId = 1;
} else {
this.menuId += 1;
}
const id = this.menuId;
this.connect("GM_registerMenuCommand", [id, name, accessKey], () => {
listener();
});
this.menuMap.set(id, name);
return id;
}
@GMContext.API()
GM_unregisterMenuCommand(id: number): void {
if (!this.menuMap) {
this.menuMap = new Map();
}
this.menuMap.delete(id);
this.sendMessage("GM_unregisterMenuCommand", [id]);
}
@GMContext.API()
CAT_userConfig() {
return this.sendMessage("CAT_userConfig", []);
}
// 此API在content页实现
@GMContext.API()
GM_addElement(parentNode: Element | string, tagName: any, attrs?: any) {
let el: Element | undefined;
// 与content页的消息通讯实际是同步,此方法不需要经过background
// 所以可以直接在then中赋值el再返回
(<MessageContent>this.message).sendCallback(
"GM_addElement",
{
param: [
typeof parentNode === "string" ? parentNode : tagName,
typeof parentNode === "string" ? tagName : attrs,
],
relatedTarget: typeof parentNode === "string" ? null : parentNode,
},
(resp) => {
el = (<MessageContent>this.message).getAndDelRelatedTarget(
resp.relatedTarget
);
}
);
return el;
}
@GMContext.API({
depend: ["CAT_fetchBlob", "CAT_createBlobUrl"],
})
async CAT_fileStorage(
action: "list" | "download" | "upload" | "delete" | "config",
details: any
) {
if (action === "config") {
this.sendMessage("CAT_fileStorage", ["config"]);
return;
}
const sendDetails: { [key: string]: string } = {
baseDir: details.baseDir || "",
path: details.path || "",
filename: details.filename,
file: details.file,
};
if (action === "upload") {
const url = await this.CAT_createBlobUrl(details.data);
sendDetails.data = url;
}
const channel = this.connect(
"CAT_fileStorage",
[action, sendDetails],
async (resp: any) => {
if (action === "download") {
// 读取blob
const blob = await this.CAT_fetchBlob(resp.data);
details.onload && details.onload(blob);
} else {
details.onload && details.onload(resp.data);
}
}
);
channel.setCatch((err) => {
if (typeof err.code === "undefined") {
details.onerror && details.onerror({ code: -1, message: err.message });
return;
}
details.onerror && details.onerror(err);
});
}
}

View File

@ -1,143 +0,0 @@
import { ExternalMessage, ExternalWhitelist } from "@App/app/const";
import MessageContent from "@App/app/message/content";
import { ScriptRunResouce } from "@App/app/repo/scripts";
import ExecScript, { ValueUpdateData } from "./exec_script";
import { addStyle, ScriptFunc } from "./utils";
// 注入脚本的沙盒环境
export default class InjectRuntime {
scripts: ScriptRunResouce[];
flag: string;
message: MessageContent;
execList: ExecScript[] = [];
constructor(
message: MessageContent,
scripts: ScriptRunResouce[],
flag: string
) {
this.message = message;
this.scripts = scripts;
this.flag = flag;
}
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);
},
});
}
});
// 监听值变化
MessageContent.getInstance().setHandler(
"valueUpdate",
(_action, data: ValueUpdateData) => {
this.execList.forEach((exec) => {
exec.valueUpdate(data);
});
}
);
// 注入允许外部调用
this.externalMessage();
}
execScript(script: ScriptRunResouce, scriptFunc: ScriptFunc) {
// @ts-ignore
delete window[script.flag];
const exec = new ExecScript(
script,
MessageContent.getInstance(),
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);
}
});
}
if (
script.metadata["run-at"] &&
script.metadata["run-at"][0] === "document-body"
) {
// 等待页面加载完成
this.waitBody(() => {
exec.exec();
});
} else {
exec.exec();
}
}
// 参考了tm的实现
waitBody(callback: () => void) {
if (document.body) {
callback();
return;
}
const listen = () => {
document.removeEventListener("load", listen, false);
document.removeEventListener("DOMNodeInserted", listen, false);
document.removeEventListener("DOMContentLoaded", listen, false);
this.waitBody(callback);
};
document.addEventListener("load", listen, false);
document.addEventListener("DOMNodeInserted", listen, false);
document.addEventListener("DOMContentLoaded", listen, false);
}
externalMessage() {
const { message } = this;
// 对外接口白名单
for (let i = 0; i < ExternalWhitelist.length; i += 1) {
if (window.location.host.endsWith(ExternalWhitelist[i])) {
// 注入
(<{ external: any }>(<unknown>window)).external = window.external || {};
(<
{
external: {
Scriptcat: {
isInstalled: (
name: string,
namespace: string,
callback: any
) => void;
};
};
}
>(<unknown>window)).external.Scriptcat = {
async isInstalled(name: string, namespace: string, callback: any) {
const resp = await message.syncSend(ExternalMessage, {
action: "isInstalled",
name,
namespace,
});
callback(resp);
},
};
(<{ external: { Tampermonkey: any } }>(
(<unknown>window)
)).external.Tampermonkey = (<{ external: { Scriptcat: any } }>(
(<unknown>window)
)).external.Scriptcat;
break;
}
}
}
}

View File

@ -1,51 +0,0 @@
import MessageInternal from "@App/app/message/internal";
import Cache from "@App/app/cache";
import { Script } from "@App/app/repo/scripts";
import CacheKey from "@App/pkg/utils/cache_key";
import IoC from "@App/app/ioc";
import Runtime, { RuntimeEvent } from "../background/runtime";
@IoC.Singleton(MessageInternal)
export default class RuntimeController {
internal: MessageInternal;
runtime!: Runtime;
constructor(internal: MessageInternal) {
this.internal = internal;
try {
this.runtime = IoC.instance(Runtime) as Runtime;
} catch (e) {
// ignore
}
}
public dispatchEvent(event: RuntimeEvent, data: any): Promise<any> {
return this.internal.syncSend(`runtime-${event}`, data);
}
// 调试脚本,需要先启动GM环境
async debugScript(script: Script) {
// 清理脚本缓存,避免GMApi中的缓存影响
Cache.getInstance().del(CacheKey.script(script.id));
Cache.getInstance().del(
CacheKey.scriptValue(script.id, script.metadata.storagename)
);
// 构建脚本代码
return this.runtime.startBackgroundScript(script);
}
watchRunStatus() {
const channel = this.internal.channel();
channel.channel("watchRunStatus");
return channel;
}
startScript(id: number) {
return this.dispatchEvent("start", id);
}
stopScript(id: number) {
return this.dispatchEvent("stop", id);
}
}

View File

@ -1,328 +0,0 @@
import MessageSandbox from "@App/app/message/sandbox";
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import {
SCRIPT_RUN_STATUS_COMPLETE,
SCRIPT_RUN_STATUS_ERROR,
SCRIPT_RUN_STATUS_RUNNING,
SCRIPT_TYPE_BACKGROUND,
SCRIPT_TYPE_CRONTAB,
ScriptRunResouce,
} from "@App/app/repo/scripts";
import { CronJob } from "cron";
import IoC from "@App/app/ioc";
import ExecScript from "./exec_script";
import { BgExecScriptWarp, CATRetryError } from "./exec_warp";
type SandboxEvent = "enable" | "disable" | "start" | "stop";
type Handler = (data: any) => Promise<any>;
// 沙盒运行环境, 后台脚本与定时脚本的运行环境
@IoC.Singleton(MessageSandbox)
export default class SandboxRuntime {
message: MessageSandbox;
logger: Logger;
cronJob: Map<number, Array<CronJob>> = new Map();
execScripts: Map<number, ExecScript> = new Map();
retryList: {
script: ScriptRunResouce;
retryTime: number;
}[] = [];
constructor(message: MessageSandbox) {
this.message = message;
this.logger = LoggerCore.getInstance().logger({ component: "sandbox" });
// 重试队列,5s检查一次
setInterval(() => {
if (!this.retryList.length) {
return;
}
const now = Date.now();
const retryList = [];
for (let i = 0; i < this.retryList.length; i += 1) {
const item = this.retryList[i];
if (item.retryTime < now) {
this.retryList.splice(i, 1);
i -= 1;
retryList.push(item.script);
}
}
retryList.forEach((script) => {
script.nextruntime = 0;
this.execScript(script);
});
}, 5000);
}
joinRetryList(script: ScriptRunResouce) {
if (script.nextruntime) {
this.retryList.push({
script,
retryTime: script.nextruntime,
});
this.retryList.sort((a, b) => a.retryTime - b.retryTime);
}
}
removeRetryList(scriptId: number) {
for (let i = 0; i < this.retryList.length; i += 1) {
if (this.retryList[i].script.id === scriptId) {
this.retryList.splice(i, 1);
i -= 1;
}
}
}
listenEvent(event: SandboxEvent, handler: Handler) {
this.message.setHandler(event, (_action, data) => {
return handler.bind(this)(data);
});
}
// 开启沙盒运行环境,监听background来的请求
init() {
this.listenEvent("enable", this.enable);
this.listenEvent("disable", this.disable);
this.listenEvent("start", this.start);
this.listenEvent("stop", this.stop);
// 监听值更新
this.message.setHandler("valueUpdate", (action, data) => {
this.execScripts.forEach((val) => {
val.valueUpdate(data);
});
});
}
// 直接运行脚本
start(script: ScriptRunResouce): Promise<boolean> {
return this.execScript(script, true);
}
stop(scriptId: number): Promise<boolean> {
const exec = this.execScripts.get(scriptId);
if (!exec) {
this.message.send("scriptRunStatus", [
scriptId,
SCRIPT_RUN_STATUS_COMPLETE,
]);
return Promise.resolve(false);
}
this.execStop(exec);
return Promise.resolve(true);
}
enable(script: ScriptRunResouce): Promise<boolean> {
// 如果正在运行,先释放
if (this.execScripts.has(script.id)) {
this.disable(script.id);
}
// 开启脚本在沙盒环境中运行
switch (script.type) {
case SCRIPT_TYPE_CRONTAB:
// 定时脚本
this.stopCronJob(script.id);
return this.crontabScript(script);
case SCRIPT_TYPE_BACKGROUND:
// 后台脚本, 直接执行脚本
return this.execScript(script);
default:
throw new Error("不支持的脚本类型");
}
}
disable(id: number): Promise<boolean> {
// 停止脚本运行,主要是停止定时器
// 后续考虑停止正在运行的脚本的方法
// 现期对于正在运行的脚本仅仅是在background中判断是否运行
// 未运行的脚本不处理GMApi的请求
this.stopCronJob(id);
// 移除重试队列
this.removeRetryList(id);
return this.stop(id);
}
// 停止计时器
stopCronJob(id: number) {
const list = this.cronJob.get(id);
if (list) {
list.forEach((val) => {
val.stop();
});
this.cronJob.delete(id);
}
}
// 执行脚本
execScript(script: ScriptRunResouce, execOnce?: boolean) {
const logger = this.logger.with({ scriptId: script.id, name: script.name });
if (this.execScripts.has(script.id)) {
// 释放掉资源
// 暂未实现执行完成后立马释放,会在下一次执行时释放
this.stop(script.id);
}
const exec = new BgExecScriptWarp(script, this.message);
this.execScripts.set(script.id, exec);
this.message.send("scriptRunStatus", [
exec.scriptRes.id,
SCRIPT_RUN_STATUS_RUNNING,
]);
// 修改掉脚本掉最后运行时间, 数据库也需要修改
script.lastruntime = new Date().getTime();
const ret = exec.exec();
if (ret instanceof Promise) {
ret
.then((resp) => {
// 发送执行完成消息
this.message.send("scriptRunStatus", [
exec.scriptRes.id,
SCRIPT_RUN_STATUS_COMPLETE,
]);
logger.info("exec script complete", {
value: resp,
});
})
.catch((err) => {
// 发送执行完成+错误消息
let errMsg;
let nextruntime = 0;
if (err instanceof CATRetryError) {
errMsg = { error: err.msg };
if (!execOnce) {
// 下一次执行时间
nextruntime = err.time.getTime();
script.nextruntime = nextruntime;
this.joinRetryList(script);
}
} else {
errMsg = Logger.E(err);
}
logger.error("exec script error", errMsg);
this.message.send("scriptRunStatus", [
exec.scriptRes.id,
SCRIPT_RUN_STATUS_ERROR,
errMsg,
nextruntime,
]);
// 错误还是抛出,方便排查
throw err;
});
} else {
logger.warn("backscript return not promise");
}
return ret;
}
crontabScript(script: ScriptRunResouce) {
// 执行定时脚本 运行表达式
if (!script.metadata.crontab) {
throw new Error("错误的crontab表达式");
}
// 如果有nextruntime,则加入重试队列
this.joinRetryList(script);
let flag = false;
const cronJobList: Array<CronJob> = [];
script.metadata.crontab.forEach((val) => {
let oncePos = 0;
let crontab = val;
if (crontab.indexOf("once") !== -1) {
const vals = crontab.split(" ");
vals.forEach((item, index) => {
if (item === "once") {
oncePos = index;
}
});
if (vals.length === 5) {
oncePos += 1;
}
crontab = crontab.replace(/once/g, "*");
}
try {
const cron = new CronJob(crontab, this.crontabExec(script, oncePos));
cron.start();
cronJobList.push(cron);
} catch (e) {
flag = true;
this.logger.error("create cronjob failed", {
script: script.id,
crontab: val,
});
}
});
if (cronJobList.length !== script.metadata.crontab.length) {
// 有表达式失败了
cronJobList.forEach((crontab) => {
crontab.stop();
});
} else {
this.cronJob.set(script.id, cronJobList);
}
return Promise.resolve(!flag);
}
crontabExec(script: ScriptRunResouce, oncePos: number) {
if (oncePos) {
return () => {
// 没有最后一次执行时间表示之前都没执行过,直接执行
if (!script.lastruntime) {
this.execScript(script);
return;
}
const now = new Date();
const last = new Date(script.lastruntime);
let flag = false;
// 根据once所在的位置去判断执行
switch (oncePos) {
case 1: // 每分钟
flag = last.getMinutes() !== now.getMinutes();
break;
case 2: // 每小时
flag = last.getHours() !== now.getHours();
break;
case 3: // 每天
flag = last.getDay() !== now.getDay();
break;
case 4: // 每月
flag = last.getMonth() !== now.getMonth();
break;
case 5: // 每周
flag = this.getWeek(last) !== this.getWeek(now);
break;
default:
}
if (flag) {
this.execScript(script);
}
};
}
return () => {
this.execScript(script);
};
}
execStop(exec: ExecScript) {
exec.stop();
this.execScripts.delete(exec.scriptRes.id);
this.message.send("scriptRunStatus", [
exec.scriptRes.id,
SCRIPT_RUN_STATUS_COMPLETE,
]);
}
// 获取本周是第几周
getWeek(date: Date) {
const nowDate = new Date(date);
const firstDay = new Date(date);
firstDay.setMonth(0); // 设置1月
firstDay.setDate(1); // 设置1号
const diffDays = Math.ceil(
(nowDate.getTime() - firstDay.getTime()) / (24 * 60 * 60 * 1000)
);
const week = Math.ceil(diffDays / 7);
return week === 0 ? 1 : week;
}
}

View File

@ -1,10 +1,10 @@
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";
// 构建脚本运行代码
export function compileScriptCode(scriptRes: ScriptRunResouce): string {
let { code } = scriptRes;
export function compileScriptCode(scriptRes: ScriptRunResouce, code: string): string {
let require = "";
if (scriptRes.metadata.require) {
scriptRes.metadata.require.forEach((val) => {
@ -52,11 +52,10 @@ function setDepend(context: { [key: string]: any }, apiVal: ApiValue) {
}
// 构建沙盒上下文
export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, message: MessageManager): GMApi {
export function createContext(scriptRes: ScriptRunResouce, GMInfo: any): GMApi {
// 按照GMApi构建
const context: { [key: string]: any } = {
scriptRes,
message,
valueChangeListener: new Map<number, { name: string; listener: GMTypes.ValueChangeListener }>(),
sendMessage: GMApi.prototype.sendMessage,
connect: GMApi.prototype.connect,
@ -77,7 +76,7 @@ export function createContext(scriptRes: ScriptRunResouce, GMInfo: any, message:
} else if (val === "GM_cookie") {
// 特殊处理GM_cookie.list之类
context[val] = api.api.bind(context);
// eslint-disable-next-line func-names, camelcase
const GM_cookie = function (action: string) {
return (
details: GMTypes.CookieDetails,
@ -135,14 +134,14 @@ Object.keys(descs).forEach((key) => {
}
});
export function warpObject(thisContext: Object, ...context: Object[]) {
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) => {
thisContext.isPrototypeOf = (name: object) => {
return Object.isPrototypeOf.call(thisContext, name) || context.some((val) => Object.isPrototypeOf.call(val, name));
};
thisContext.propertyIsEnumerable = (name: PropertyKey) => {
@ -187,7 +186,6 @@ export function proxyContext(global: any, context: any, thisContext?: { [key: st
case "window":
case "self":
case "globalThis":
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return proxy;
case "top":
case "parent":
@ -330,6 +328,7 @@ export function proxyContext(global: any, context: any, thisContext?: { [key: st
}
ret = Object.getOwnPropertyDescriptor(global, name);
return ret;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return undefined;
}