消息通讯

This commit is contained in:
王一之 2024-12-27 17:48:15 +08:00
parent eeb343bc5f
commit 78152222f3
29 changed files with 4051 additions and 252 deletions

View File

@ -0,0 +1,32 @@
export function sendMessage(action: string, params?: any): Promise<any> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, data: params }, (res) => {
if (res.code) {
console.error(res);
reject(res.message);
} else {
resolve(res.data);
}
});
});
}
export function connect(action: string, params?: any): Promise<chrome.runtime.Port> {
return new Promise((resolve) => {
const port = chrome.runtime.connect();
port.postMessage({ action, data: params });
resolve(port);
});
}
export class Client {
constructor(private prefix: string) {
if (!this.prefix.endsWith("/")) {
this.prefix += "/";
}
}
do(action: string, params?: any): Promise<any> {
return sendMessage(this.prefix + action, params);
}
}

View File

@ -1,12 +1,12 @@
import { ApiFunction } from "./server"; import { connect } from "./client";
import { ApiFunction, Server } from "./server";
export class Broker { export class Broker {
constructor() {} constructor() {}
// 订阅 // 订阅
subscribe(topic: string, handler: (message: any) => void) { async subscribe(topic: string, handler: (message: any) => void) {
const con = chrome.runtime.connect({ name: topic }); const con = await connect("messageQueue", { action: "subscribe", topic });
con.postMessage({ action: "subscribe", topic });
con.onMessage.addListener((msg: { action: string; topic: string; message: any }) => { con.onMessage.addListener((msg: { action: string; topic: string; message: any }) => {
if (msg.action === "message") { if (msg.action === "message") {
handler(msg.message); handler(msg.message);
@ -24,6 +24,10 @@ export class Broker {
export class MessageQueue { export class MessageQueue {
topicConMap: Map<string, { name: string; con: chrome.runtime.Port }[]> = new Map(); topicConMap: Map<string, { name: string; con: chrome.runtime.Port }[]> = new Map();
constructor(api: Server) {
api.on("messageQueue", this.handler());
}
handler(): ApiFunction { handler(): ApiFunction {
return ({ action, topic, message }: { action: string; topic: string; message: any }, con) => { return ({ action, topic, message }: { action: string; topic: string; message: any }, con) => {
if (!con) { if (!con) {
@ -53,7 +57,10 @@ export class MessageQueue {
} }
list.push({ name: topic, con }); list.push({ name: topic, con });
con.onDisconnect.addListener(() => { con.onDisconnect.addListener(() => {
let list = this.topicConMap.get(topic);
// 移除断开连接的con
list = list!.filter((item) => item.con !== con); list = list!.filter((item) => item.con !== con);
this.topicConMap.set(topic, list);
}); });
} }

View File

@ -5,18 +5,22 @@ export class Server {
constructor(private env: string) { constructor(private env: string) {
chrome.runtime.onConnect.addListener((port) => { chrome.runtime.onConnect.addListener((port) => {
const handler = (msg: { action: string }) => { const handler = (msg: { action: string; data: any }) => {
port.onMessage.removeListener(handler); port.onMessage.removeListener(handler);
this.connectHandle(msg.action, msg, port); this.connectHandle(msg.action, msg.data, port);
}; };
port.onMessage.addListener(handler); port.onMessage.addListener(handler);
}); });
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
this.messageHandle(msg.action, msg, sender, sendResponse); this.messageHandle(msg.action, msg.data, sender, sendResponse);
}); });
} }
group(name: string) {
return new Group(this, name);
}
on(name: string, func: ApiFunction) { on(name: string, func: ApiFunction) {
this.apiFunctionMap.set(name, func); this.apiFunctionMap.set(name, func);
} }
@ -45,3 +49,22 @@ export class Server {
} }
} }
} }
export class Group {
constructor(
private server: Server,
private name: string
) {
if (!name.endsWith("/")) {
this.name += "/";
}
}
group(name: string) {
return new Group(this.server, `${this.name}${name}`);
}
on(name: string, func: ApiFunction) {
this.server.on(`${this.name}${name}`, func);
}
}

View File

@ -28,6 +28,7 @@ export default defineConfig({
sandbox: `${src}/sandbox.ts`, sandbox: `${src}/sandbox.ts`,
popup: `${src}/pages/popup/main.tsx`, popup: `${src}/pages/popup/main.tsx`,
install: `${src}/pages/install/main.tsx`, install: `${src}/pages/install/main.tsx`,
options: `${src}/pages/options/main.tsx`,
}, },
output: { output: {
path: `${dist}/ext/src`, path: `${dist}/ext/src`,
@ -130,12 +131,20 @@ export default defineConfig({
}), }),
new rspack.HtmlRspackPlugin({ new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/install.html`, filename: `${dist}/ext/src/install.html`,
template: `${src}/pages/install/index.html`, template: `${src}/pages/template.html`,
inject: "head", inject: "head",
title: "Install - ScriptCat", title: "Install - ScriptCat",
minify: true, minify: true,
chunks: ["install"], chunks: ["install"],
}), }),
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/options.html`,
template: `${src}/pages/template.html`,
inject: "head",
title: "Home - ScriptCat",
minify: true,
chunks: ["options"],
}),
new rspack.HtmlRspackPlugin({ new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/popup.html`, filename: `${dist}/ext/src/popup.html`,
template: `${src}/pages/popup/index.html`, template: `${src}/pages/popup/index.html`,

View File

@ -1,43 +1,6 @@
// 缓存key,所有缓存相关的key都需要定义在此
// 使用装饰器维护缓存值
import { ConfirmParam } from "@App/runtime/background/permission_verify";
export default class CacheKey { export default class CacheKey {
// 缓存触发器 // 加载脚本信息时的缓存
static Trigger(): (target: unknown, propertyName: string, descriptor: PropertyDescriptor) => void { static scriptInstallInfo(uuid: string): string {
return (target, propertyName, descriptor) => {
descriptor.value();
};
}
// 脚本缓存
static script(id: number): string {
return `script:${id.toString()}`;
}
// 加载脚本信息时的缓存,已处理删除
static scriptInfo(uuid: string): string {
return `scriptInfo:${uuid}`; return `scriptInfo:${uuid}`;
} }
// 脚本资源url缓存,可能存在泄漏
static resourceByUrl(url: string): string {
return `resource:${url}`;
}
// 脚本value缓存,可能存在泄漏
static scriptValue(id: number, storagename?: string[]): string {
if (storagename) {
return `value:storagename:${storagename[0]}`;
}
return `value:id:${id.toString()}`;
}
static permissionConfirm(scriptId: number, confirm: ConfirmParam): string {
return `permission:${scriptId.toString()}:${confirm.permissionValue || ""}:${confirm.permission || ""}`;
}
static importInfo(uuid: string): string {
return `import:${uuid}`;
}
} }

View File

@ -1,4 +1,3 @@
import EventEmitter from "eventemitter3";
import Logger from "./logger"; import Logger from "./logger";
export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLevel = "debug" | "info" | "warn" | "error";
@ -20,7 +19,7 @@ export default class LoggerCore {
return LoggerCore.instance; return LoggerCore.instance;
} }
static getLogger(...label: LogLabel[]) { static logger(...label: LogLabel[]) {
return LoggerCore.getInstance().logger(...label); return LoggerCore.getInstance().logger(...label);
} }
@ -45,6 +44,4 @@ export default class LoggerCore {
logger(...label: LogLabel[]) { logger(...label: LogLabel[]) {
return new Logger(this, this.labels, ...label); return new Logger(this, this.labels, ...label);
} }
static EE = new EventEmitter();
} }

View File

@ -1,4 +1,3 @@
/* eslint-disable no-console */
import dayjs from "dayjs"; import dayjs from "dayjs";
import LoggerCore, { LogLabel, LogLevel } from "./core"; import LoggerCore, { LogLabel, LogLevel } from "./core";
@ -54,7 +53,6 @@ export default class Logger {
break; break;
} }
} }
LoggerCore.EE.emit("log", { level, message, label });
} }
with(...label: LogLabel[]) { with(...label: LogLabel[]) {

View File

@ -32,6 +32,10 @@ function renameField(): void {
// export是0.10.x时的兼容性处理 // export是0.10.x时的兼容性处理
export: "++id,&scriptId", export: "++id,&scriptId",
}); });
// 将脚本数据迁移到chrome.storage
// db.version(18)
// .stores({})
// .upgrade((tx) => {});
} }
export default function migrate() { export default function migrate() {

58
src/app/repo/repo.ts Normal file
View File

@ -0,0 +1,58 @@
export abstract class Repo<T> {
constructor(private prefix: string) {
if (!prefix.endsWith(":")) {
prefix += ":";
}
}
private joinKey(key: string) {
return this.prefix + key;
}
public async _save(key: string, val: T) {
return new Promise((resolve) => {
const data = {
[this.joinKey(key)]: val,
};
chrome.storage.local.set(data, () => {
resolve(val);
});
});
}
public get(key: string): Promise<T | undefined> {
return new Promise((resolve) => {
key = this.joinKey(key);
chrome.storage.local.get(key, (result) => {
resolve(result[key]);
});
});
}
public find(filters?: (key: string, value: T) => boolean): Promise<T[]> {
return new Promise((resolve) => {
chrome.storage.local.get((result) => {
const ret = [];
for (const key in result) {
if (key.startsWith(this.prefix) && (!filters || filters(key, result[key]))) {
ret.push(result[key]);
}
}
resolve(ret);
});
});
}
findOne(filters?: (key: string, value: T) => boolean): Promise<T | undefined> {
return new Promise((resolve) => {
chrome.storage.local.get((result) => {
for (const key in result) {
if (key.startsWith(this.prefix) && (!filters || filters(key, result[key]))) {
return resolve(result[key]);
}
}
resolve(undefined);
});
});
}
}

View File

@ -1,4 +1,4 @@
import { DAO, db } from "./dao"; import { Repo } from "./repo";
import { Resource } from "./resource"; import { Resource } from "./resource";
import { Value } from "./value"; import { Value } from "./value";
@ -26,14 +26,7 @@ export const SCRIPT_RUN_STATUS_RETRY: SCRIPT_RUN_STATUS = "retry";
export type Metadata = { [key: string]: string[] }; export type Metadata = { [key: string]: string[] };
export type ConfigType = export type ConfigType = "text" | "checkbox" | "select" | "mult-select" | "number" | "textarea" | "time";
| "text"
| "checkbox"
| "select"
| "mult-select"
| "number"
| "textarea"
| "time";
export interface Config { export interface Config {
[key: string]: any; [key: string]: any;
@ -88,34 +81,40 @@ export interface ScriptRunResouce extends Script {
sourceCode: string; sourceCode: string;
} }
export class ScriptDAO extends DAO<Script> { export class ScriptDAO extends Repo<Script> {
public tableName = "scripts";
constructor() { constructor() {
super(); super("script");
this.table = db.table(this.tableName); }
public save(val: Script) {
return super._save(val.uuid, val);
} }
public findByName(name: string) { public findByName(name: string) {
return this.findOne({ name }); return this.findOne((key, value) => {
return value.name === name;
});
} }
public findByNameAndNamespace(name: string, namespace?: string) { public findByNameAndNamespace(name: string, namespace?: string) {
if (namespace) { return this.findOne((key, value) => {
return this.findOne({ name, namespace }); return value.name === name && (!namespace || value.namespace === namespace);
} });
return this.findOne({ name });
} }
public findByUUID(uuid: string) { public findByUUID(uuid: string) {
return this.findOne({ uuid }); return this.get(uuid);
} }
public findByUUIDAndSubscribeUrl(uuid: string, suburl: string) { public findByUUIDAndSubscribeUrl(uuid: string, suburl: string) {
return this.findOne({ subscribeUrl: suburl, uuid }); return this.findOne((key, value) => {
return value.uuid === uuid && value.subscribeUrl === suburl;
});
} }
public findByOriginAndSubscribeUrl(origin: string, suburl: string) { public findByOriginAndSubscribeUrl(origin: string, suburl: string) {
return this.findOne({ subscribeUrl: suburl, origin }); return this.findOne((key, value) => {
return value.origin === origin && value.subscribeUrl === suburl;
});
} }
} }

View File

@ -1,4 +1,4 @@
import { DAO, db } from "./dao"; import { Repo } from "./repo";
export type Metadata = { [key: string]: string[] }; export type Metadata = { [key: string]: string[] };
@ -25,15 +25,12 @@ export interface Subscribe {
checktime: number; checktime: number;
} }
export class SubscribeDAO extends DAO<Subscribe> { export class SubscribeDAO extends Repo<Subscribe> {
public tableName = "subscribe";
constructor() { constructor() {
super(); super("subscribe");
this.table = db.table(this.tableName);
} }
public findByUrl(url: string) { public findByUrl(url: string) {
return this.findOne({ url }); return this.findOne((key, value) => value.url === url);
} }
} }

View File

@ -0,0 +1,17 @@
import { Script } from "@App/app/repo/scripts";
import { Client } from "@Packages/message/client";
export class ScriptClient extends Client {
constructor() {
super("serviceWorker/script");
}
// 获取安装信息
getInstallInfo(uuid: string) {
return this.do("getInstallInfo", uuid);
}
installScript(script: Script) {
return this.do("installScript", script);
}
}

View File

@ -1,11 +1,6 @@
import { fetchScriptInfo } from "@App/pkg/utils/script";
import { v4 as uuidv4 } from "uuid";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
import { openInCurrentTab } from "@App/pkg/utils/utils";
import LoggerCore from "@App/app/logger/core";
import { Server } from "@Packages/message/server"; import { Server } from "@Packages/message/server";
import { MessageQueue } from "@Packages/message/message_queue"; import { MessageQueue } from "@Packages/message/message_queue";
import { ScriptService } from "./script";
export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode"; export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode";
@ -13,128 +8,13 @@ export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode";
export default class ServiceWorkerManager { export default class ServiceWorkerManager {
constructor() {} constructor() {}
listenerScriptInstall() {
// 初始化脚本安装监听
chrome.webRequest.onCompleted.addListener(
(req: chrome.webRequest.WebResponseCacheDetails) => {
// 处理url, 实现安装脚本
if (req.method !== "GET") {
return;
}
const url = new URL(req.url);
// 判断是否有hash
if (!url.hash) {
return;
}
// 判断是否有url参数
if (!url.hash.includes("url=")) {
return;
}
// 获取url参数
const targetUrl = url.hash.split("url=")[1];
// 读取脚本url内容, 进行安装
LoggerCore.getInstance().logger().debug("install script", { url: targetUrl });
this.openInstallPageByUrl(targetUrl).catch(() => {
// 如果打开失败, 则重定向到安装页
chrome.scripting.executeScript({
target: { tabId: req.tabId },
func: function () {
history.back();
},
});
// 并不再重定向当前url
chrome.declarativeNetRequest.updateDynamicRules(
{
removeRuleIds: [2],
addRules: [
{
id: 2,
priority: 1,
action: {
type: chrome.declarativeNetRequest.RuleActionType.ALLOW,
},
condition: {
regexFilter: targetUrl,
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
},
},
],
},
() => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError);
}
}
);
});
},
{
urls: [
"https://docs.scriptcat.org/docs/script_installation",
"https://www.tampermonkey.net/script_installation.php",
],
types: ["main_frame"],
}
);
// 重定向到脚本安装页
chrome.declarativeNetRequest.updateDynamicRules(
{
removeRuleIds: [1],
addRules: [
{
id: 1,
priority: 1,
action: {
type: chrome.declarativeNetRequest.RuleActionType.REDIRECT,
redirect: {
regexSubstitution: "https://docs.scriptcat.org/docs/script_installation#url=\\0",
},
},
condition: {
regexFilter: "^([^#]+?)\\.user(\\.bg|\\.sub)?\\.js((\\?).*|$)",
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
// 排除常见的复合上述条件的域名
excludedRequestDomains: ["github.com"],
},
},
],
},
() => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError);
}
}
);
}
public openInstallPageByUrl(url: string) {
return fetchScriptInfo(url, "user", false, uuidv4()).then((info) => {
Cache.getInstance().set(CacheKey.scriptInfo(info.uuid), info);
setTimeout(() => {
// 清理缓存
Cache.getInstance().del(CacheKey.scriptInfo(info.uuid));
}, 60 * 1000);
openInCurrentTab(`/src/install.html?uuid=${info.uuid}`);
});
}
private api: Server = new Server("service_worker"); private api: Server = new Server("service_worker");
private mq: MessageQueue = new MessageQueue(); private mq: MessageQueue = new MessageQueue(this.api);
// 获取安装信息
getInstallInfo(params: { uuid: string }) {
const info = Cache.getInstance().get(CacheKey.scriptInfo(params.uuid));
return info;
}
initManager() { initManager() {
// 监听消息 const group = this.api.group("serviceWorker");
this.api.on("getInstallInfo", this.getInstallInfo); const script = new ScriptService(group.group("script"), this.mq);
this.api.on("messageQueue", this.mq.handler()); script.init();
this.listenerScriptInstall();
} }
} }

View File

@ -0,0 +1,157 @@
import { fetchScriptInfo } from "@App/pkg/utils/script";
import { v4 as uuidv4 } from "uuid";
import { Group } from "@Packages/message/server";
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 { Script, ScriptDAO } from "@App/app/repo/scripts";
import { MessageQueue } from "@Packages/message/message_queue";
export class ScriptService {
logger: Logger;
constructor(
private group: Group,
private mq: MessageQueue
) {
this.logger = LoggerCore.logger().with({ service: "script" });
}
listenerScriptInstall() {
// 初始化脚本安装监听
chrome.webRequest.onBeforeRequest.addListener(
(req: chrome.webRequest.WebRequestBodyDetails) => {
// 处理url, 实现安装脚本
if (req.method !== "GET") {
return;
}
const url = new URL(req.url);
// 判断是否有hash
if (!url.hash) {
return;
}
// 判断是否有url参数
if (!url.hash.includes("url=")) {
return;
}
// 获取url参数
const targetUrl = url.hash.split("url=")[1];
// 读取脚本url内容, 进行安装
const logger = this.logger.with({ url: targetUrl });
logger.debug("install script");
this.openInstallPageByUrl(targetUrl).catch((e) => {
logger.error("install script error", Logger.E(e));
// 如果打开失败, 则重定向到安装页
chrome.scripting.executeScript({
target: { tabId: req.tabId },
func: function () {
history.back();
},
});
// 并不再重定向当前url
chrome.declarativeNetRequest.updateDynamicRules(
{
removeRuleIds: [2],
addRules: [
{
id: 2,
priority: 1,
action: {
type: chrome.declarativeNetRequest.RuleActionType.ALLOW,
},
condition: {
regexFilter: targetUrl,
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
},
},
],
},
() => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError);
}
}
);
});
},
{
urls: [
"https://docs.scriptcat.org/docs/script_installation",
"https://www.tampermonkey.net/script_installation.php",
],
types: ["main_frame"],
}
);
// 重定向到脚本安装页
chrome.declarativeNetRequest.updateDynamicRules(
{
removeRuleIds: [1, 2],
addRules: [
{
id: 1,
priority: 1,
action: {
type: chrome.declarativeNetRequest.RuleActionType.REDIRECT,
redirect: {
regexSubstitution: "https://docs.scriptcat.org/docs/script_installation#url=\\0",
},
},
condition: {
regexFilter: "^([^#]+?)\\.user(\\.bg|\\.sub)?\\.js((\\?).*|$)",
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
// 排除常见的符合上述条件的域名
excludedRequestDomains: ["github.com"],
},
},
],
},
() => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError);
}
}
);
}
public openInstallPageByUrl(url: string) {
const uuid = uuidv4();
return fetchScriptInfo(url, "user", false, uuidv4()).then((info) => {
Cache.getInstance().set(CacheKey.scriptInstallInfo(uuid), info);
setTimeout(() => {
// 清理缓存
Cache.getInstance().del(CacheKey.scriptInstallInfo(uuid));
}, 60 * 1000);
openInCurrentTab(`/src/install.html?uuid=${uuid}`);
});
}
// 获取安装信息
getInstallInfo(uuid: string) {
return Cache.getInstance().get(CacheKey.scriptInstallInfo(uuid));
}
// 安装脚本
async installScript(script: Script) {
const dao = new ScriptDAO();
// 判断是否已经安装
const oldScript = await dao.findByUUID(script.uuid);
if (!oldScript) {
// 执行安装逻辑
} else {
// 执行更新逻辑
}
// 广播一下
this.mq.publish("installScript", script);
}
init() {
this.listenerScriptInstall();
this.group.on("getInstallInfo", this.getInstallInfo);
this.group.on("installScript", this.installScript.bind(this));
}
}

View File

@ -18,6 +18,7 @@
}, },
"default_locale": "zh_CN", "default_locale": "zh_CN",
"permissions": [ "permissions": [
"storage",
"offscreen", "offscreen",
"scripting", "scripting",
"activeTab", "activeTab",

View File

@ -5,7 +5,9 @@ import { Metadata, Script, SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE } from "@
import { Subscribe } from "@App/app/repo/subscribe"; import { Subscribe } from "@App/app/repo/subscribe";
import { i18nDescription, i18nName } from "@App/locales/locales"; import { i18nDescription, i18nName } from "@App/locales/locales";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScriptInfo } from "@App/pkg/utils/script"; import { prepareScriptByCode, prepareSubscribeByCode, ScriptInfo } from "@App/pkg/utils/script";
import { isDebug, nextTime } from "@App/pkg/utils/utils";
import { ScriptClient } from "@App/app/service/service_worker/client";
type Permission = { label: string; color?: string; value: string[] }[]; type Permission = { label: string; color?: string; value: string[] }[];
@ -14,28 +16,74 @@ const closeWindow = () => {
}; };
function App() { function App() {
const [permission, setPermission] = useState<Permission>([]); // 脚本信息包括脚本代码、下载url、metadata等信息通过service_worker的缓存获取
const [metadata, setMetadata] = useState<Metadata>({}); const [scriptInfo, setScriptInfo] = useState<ScriptInfo>();
// 脚本信息包括脚本代码、下载url但是不包括解析代码后得到的metadata通过background的缓存获取
const [info, setInfo] = useState<ScriptInfo>();
// 对脚本详细的描述
const [description, setDescription] = useState<any>();
// 是系统检测到脚本更新时打开的窗口会有一个倒计时 // 是系统检测到脚本更新时打开的窗口会有一个倒计时
const [countdown, setCountdown] = useState<number>(-1); const [countdown, setCountdown] = useState<number>(-1);
// 是否为更新
const [isUpdate, setIsUpdate] = useState<boolean>(false);
// 脚本信息 // 脚本信息
const [upsertScript, setUpsertScript] = useState<Script | Subscribe>(); const [upsertScript, setUpsertScript] = useState<Script | Subscribe>();
// 更新的情况下会有老版本的脚本信息 // 更新的情况下会有老版本的脚本信息
const [oldScript, setOldScript] = useState<Script | Subscribe>(); const [oldScript, setOldScript] = useState<Script | Subscribe>();
// 脚本开启状态 // 脚本开启状态
const [enable, setEnable] = useState<boolean>(false); const [enable, setEnable] = useState<boolean>(false);
// 是否是订阅脚本
const [isSub, setIsSub] = useState<boolean>(false);
// 按钮文案 // 按钮文案
const [btnText, setBtnText] = useState<string>(); const [btnText, setBtnText] = useState<string>("");
const { t } = useTranslation(); const { t } = useTranslation();
const metadata: Metadata = scriptInfo?.metadata || {};
const permission: Permission = [];
const isUpdate = scriptInfo?.update;
const description = [];
if (scriptInfo) {
if (scriptInfo.userSubscribe) {
permission.push({
label: t("subscribe_install_label"),
color: "#ff0000",
value: metadata.scripturl,
});
}
if (metadata.match) {
permission.push({ label: t("script_runs_in"), value: metadata.match });
}
if (metadata.connect) {
permission.push({
label: t("script_has_full_access_to"),
color: "#F9925A",
value: metadata.connect,
});
}
if (metadata.require) {
permission.push({ label: t("script_requires"), value: metadata.require });
}
let isCookie = false;
metadata.grant?.forEach((val) => {
if (val === "GM_cookie") {
isCookie = true;
}
});
if (isCookie) {
description.push(
<Typography.Text type="error" key="cookie">
{t("cookie_warning")}
</Typography.Text>
);
}
if (metadata.crontab) {
description.push(<Typography.Text key="crontab">{t("scheduled_script_description_1")}</Typography.Text>);
description.push(
<Typography.Text key="cronta-nexttime">
{t("scheduled_script_description_2", {
expression: metadata.crontab[0],
time: nextTime(metadata.crontab[0]),
})}
</Typography.Text>
);
} else if (metadata.background) {
description.push(<Typography.Text key="background">{t("background_script_description")}</Typography.Text>);
}
}
// 不推荐的内容标签与描述 // 不推荐的内容标签与描述
const antifeatures: { const antifeatures: {
[key: string]: { color: string; title: string; description: string }; [key: string]: { color: string; title: string; description: string };
@ -78,12 +126,46 @@ function App() {
if (!uuid) { if (!uuid) {
return; return;
} }
new ScriptClient()
.getInstallInfo(uuid)
.then(async (info: ScriptInfo) => {
if (!info) {
throw new Error("fetch script info failed");
}
// 如果是更新的情况下, 获取老版本的脚本信息
let prepare: { script: Script; oldScript?: Script } | { subscribe: Subscribe; oldSubscribe?: Subscribe };
let action: Script | Subscribe;
if (info.userSubscribe) {
prepare = await prepareSubscribeByCode(info.code, info.url);
action = prepare.subscribe;
setOldScript(prepare.oldSubscribe);
} else {
if (info.update) {
prepare = await prepareScriptByCode(info.code, info.url, info.uuid);
} else {
prepare = await prepareScriptByCode(info.code, info.url);
}
action = prepare.script;
setOldScript(prepare.oldScript);
}
if (info.userSubscribe) {
setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe"));
} else {
setBtnText(isUpdate ? t("update_script")! : t("install_script"));
}
setScriptInfo(info);
setEnable(action.status === SCRIPT_STATUS_ENABLE);
setUpsertScript(action);
})
.catch(() => {
Message.error(t("script_info_load_failed"));
}); });
}, [t]);
return ( return (
<div className="h-full"> <div className="h-full">
<div className="h-full"> <div className="h-full">
<Grid.Row gutter={8}> <Grid.Row className="mb-2" gutter={8}>
<Grid.Col flex={1} className="flex-col p-8px"> <Grid.Col flex={1} className="flex-col p-8px">
<Space direction="vertical"> <Space direction="vertical">
<div> <div>
@ -94,7 +176,9 @@ function App() {
)} )}
<Typography.Text bold className="text-size-lg"> <Typography.Text bold className="text-size-lg">
{upsertScript && i18nName(upsertScript)} {upsertScript && i18nName(upsertScript)}
<Tooltip content={isSub ? t("subscribe_source_tooltip") : t("script_status_tooltip")}> <Tooltip
content={scriptInfo?.userSubscribe ? t("subscribe_source_tooltip") : t("script_status_tooltip")}
>
<Switch <Switch
style={{ marginLeft: "8px" }} style={{ marginLeft: "8px" }}
checked={enable} checked={enable}
@ -131,7 +215,7 @@ function App() {
overflowY: "auto", overflowY: "auto",
}} }}
> >
{t("source")}: {info?.url} {t("source")}: {scriptInfo?.url}
</Typography.Text> </Typography.Text>
</div> </div>
<div className="text-end"> <div className="text-end">
@ -144,7 +228,7 @@ function App() {
Message.error(t("script_info_load_failed")!); Message.error(t("script_info_load_failed")!);
return; return;
} }
if (isSub) { if (scriptInfo?.userSubscribe) {
// subscribeCtrl // subscribeCtrl
// .upsert(upsertScript as Subscribe) // .upsert(upsertScript as Subscribe)
// .then(() => { // .then(() => {
@ -159,23 +243,25 @@ function App() {
// }); // });
return; return;
} }
// scriptCtrl new ScriptClient()
// .upsert(upsertScript as Script) .installScript(upsertScript as Script)
// .then(() => { .then(() => {
// if (isUpdate) { if (isUpdate) {
// Message.success(t("install.update_success")!); Message.success(t("install.update_success")!);
// setBtnText(t("install.update_success")!); setBtnText(t("install.update_success")!);
// } else { } else {
// Message.success(t("install_success")!); Message.success(t("install_success")!);
// setBtnText(t("install_success")!); setBtnText(t("install_success")!);
// } }
// setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions
// closeWindow(); !isDebug() &&
// }, 200); setTimeout(() => {
// }) closeWindow();
// .catch((e) => { }, 200);
// Message.error(`${t("install_failed")}: ${e}`); })
// }); .catch((e) => {
Message.error(`${t("install_failed")}: ${e}`);
});
}} }}
> >
{btnText} {btnText}

View File

@ -0,0 +1,41 @@
.show-log-card .arco-list-item {
border-bottom: 0 !important;
}
h1.arco-typography,
h2.arco-typography,
h3.arco-typography,
h4.arco-typography,
h5.arco-typography,
h6.arco-typography {
margin-top: 0 !important;
}
.script-list .arco-card-body {
padding: 0 !important;
}
.max-table-cell .arco-table-cell {
display: block;
max-height: 100px;
overflow: auto;
}
/* error、wran图标直接用的油猴CodeMirror编辑器图标 待优化*/
.icon-error{
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: center;
left: 10px!important;
}
.icon-warn{
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: center;
left: 10px!important;
}
.actionList{
height: auto !important;
}

View File

@ -0,0 +1,25 @@
import React from "react";
import ReactDOM from "react-dom/client";
import MainLayout from "../components/layout/MainLayout.tsx";
import "@arco-design/web-react/dist/css/arco.css";
import "@App/locales/locales";
import "@App/index.css";
import { Provider } from "react-redux";
import { store } from "@App/store/store.ts";
import { Broker } from "@Packages/message/message_queue.ts";
// 测试监听广播
const border = new Broker();
border.subscribe("installScript", (message) => {
console.log(message);
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<MainLayout className="!flex-col !px-4 box-border">p</MainLayout>
</Provider>
</React.StrictMode>
);

View File

@ -0,0 +1,362 @@
import React, { useEffect } from "react";
import {
BackTop,
Button,
Card,
DatePicker,
Input,
List,
Message,
Space,
} from "@arco-design/web-react";
import dayjs from "dayjs";
import Text from "@arco-design/web-react/es/Typography/text";
import { Logger, LoggerDAO } from "@App/app/repo/logger";
import LogLabel, { Labels, Query } from "@App/pages/components/LogLabel";
import { IconPlus } from "@arco-design/web-react/icon";
import { useSearchParams } from "react-router-dom";
import { formatUnixTime } from "@App/pkg/utils/utils";
import { SystemConfig } from "@App/pkg/config/config";
import IoC from "@App/app/ioc";
import { useTranslation } from "react-i18next";
function LoggerPage() {
const [labels, setLabels] = React.useState<Labels>({});
const defaultQuery = JSON.parse(useSearchParams()[0].get("query") || "[{}]");
const [init, setInit] = React.useState(0);
const [querys, setQuerys] = React.useState<Query[]>(defaultQuery);
const [logs, setLogs] = React.useState<Logger[]>([]);
const [queryLogs, setQueryLogs] = React.useState<Logger[]>([]);
const [search, setSearch] = React.useState<string>("");
const [startTime, setStartTime] = React.useState(
dayjs().subtract(24, "hour").unix()
);
const [endTime, setEndTime] = React.useState(dayjs().unix());
const loggerDAO = new LoggerDAO();
const systemConfig = IoC.instance(SystemConfig) as SystemConfig;
const { t } = useTranslation();
const onQueryLog = () => {
const newQueryLogs: Logger[] = [];
const regex = search && new RegExp(search);
logs.forEach((log) => {
for (let i = 0; i < querys.length; i += 1) {
const query = querys[i];
if (query.key) {
const value = log.label[query.key];
switch (query.condition) {
case "=":
// eslint-disable-next-line eqeqeq
if (value != query.value) {
return;
}
break;
case "=~":
if (
typeof value === "string" &&
value.indexOf(query.value) === -1
) {
return;
}
break;
case "!=":
// eslint-disable-next-line eqeqeq
if (value == query.value) {
return;
}
break;
case "!~":
if (
typeof value === "string" &&
value.indexOf(query.value) === -1
) {
return;
}
break;
default:
// eslint-disable-next-line eqeqeq
if (value != query.value) {
return;
}
break;
}
}
}
if (regex && !regex.test(log.message)) {
return;
}
newQueryLogs.push(log);
});
setInit(4);
setQueryLogs(newQueryLogs);
};
useEffect(() => {
if (init === 1 && defaultQuery.length && defaultQuery[0].key) {
onQueryLog();
setInit(2);
}
}, [init]);
useEffect(() => {
loggerDAO.queryLogs(startTime * 1000, endTime * 1000).then((l) => {
setLogs(l);
// 计算标签
const newLabels = labels;
l.forEach((log) => {
Object.keys(log.label).forEach((key) => {
if (!newLabels[key]) {
newLabels[key] = {};
}
const value = log.label[key];
switch (typeof value) {
case "string":
case "number":
newLabels[key][value] = true;
break;
default:
break;
}
});
});
setLabels(newLabels);
setQueryLogs([]);
if (init === 0) {
setInit(1);
}
});
}, [startTime, endTime]);
return (
<>
<BackTop
visibleHeight={30}
style={{ position: "absolute" }}
target={() => document.getElementById("backtop")!}
/>
<div
id="backtop"
style={{
height: "100%",
overflow: "auto",
position: "relative",
}}
>
<Space
direction="vertical"
className="log-space"
style={{
width: "100%",
}}
>
<Card
bordered={false}
title={t("log_title")}
extra={
<Space size="large">
<DatePicker.RangePicker
style={{ width: 400 }}
showTime
shortcutsPlacementLeft
value={[startTime * 1000, endTime * 1000]}
onChange={(_, time) => {
setStartTime(time[0].unix());
setEndTime(time[1].unix());
}}
shortcuts={[
{
text: t("last_5_minutes"),
value: () => [dayjs(), dayjs().add(-5, "minute")],
},
{
text: t("last_15_minutes"),
value: () => [dayjs(), dayjs().add(-15, "minute")],
},
{
text: t("last_30_minutes"),
value: () => [dayjs(), dayjs().add(-30, "minute")],
},
{
text: t("last_1_hour"),
value: () => [dayjs(), dayjs().add(-1, "hour")],
},
{
text: t("last_3_hours"),
value: () => [dayjs(), dayjs().add(-3, "hour")],
},
{
text: t("last_6_hours"),
value: () => [dayjs(), dayjs().add(-6, "hour")],
},
{
text: t("last_12_hours"),
value: () => [dayjs(), dayjs().add(-12, "hour")],
},
{
text: t("last_24_hours"),
value: () => [dayjs(), dayjs().add(-24, "hour")],
},
{
text: t("last_7_days"),
value: () => [dayjs(), dayjs().add(-7, "day")],
},
]}
/>
<Button type="primary" onClick={onQueryLog}>
{t("query")}
</Button>
</Space>
}
>
<Space direction="vertical">
<Space
style={{
background: "var(--color-neutral-1)",
padding: 8,
}}
direction="vertical"
>
<div className="text-sm font-medium">{t("labels")}</div>
<Space>
{querys.map((query, index) => (
<LogLabel
key={query.key + query.value}
value={query}
labels={labels}
onChange={(v) => {
querys[index] = v;
setQuerys([...querys]);
}}
onClose={() => {
querys.splice(index, 1);
setQuerys([...querys]);
}}
/>
))}
<Button
iconOnly
onClick={() => {
setQuerys([
...querys,
{
key: "",
condition: "=",
value: "",
},
]);
}}
icon={<IconPlus />}
/>
</Space>
</Space>
<Space
style={{
background: "var(--color-neutral-1)",
padding: 8,
}}
direction="vertical"
>
<div className="text-sm font-medium">{t("search_regex")}</div>
<Input value={search} onChange={(e) => setSearch(e)} />
</Space>
</Space>
</Card>
<Card
className="show-log-card"
bordered={false}
title={t("logs")}
extra={
<Space>
<Space>
<span>{t("clean_schedule")}</span>
<Input
defaultValue={systemConfig.logCleanCycle.toString()}
style={{
width: "60px",
}}
type="number"
onChange={(e) => {
systemConfig.logCleanCycle = parseInt(e, 10);
}}
/>
<span>{t("days_ago_logs")}</span>
</Space>
<Button
type="primary"
status="warning"
onClick={() => {
queryLogs.forEach((log) => {
loggerDAO.delete(log.id);
});
setQueryLogs([]);
setLogs([]);
Message.info(t("delete_completed")!);
}}
>
{t("delete_current_logs")}
</Button>
<Button
type="primary"
status="danger"
onClick={() => {
loggerDAO.clear();
setQueryLogs([]);
setLogs([]);
Message.info(t("clear_completed")!);
}}
>
{t("clear_logs")}
</Button>
</Space>
}
style={{
padding: 8,
height: "100%",
boxSizing: "border-box",
}}
>
<Text>
{formatUnixTime(startTime)} {t("to")} {formatUnixTime(endTime)}{" "}
{t("total_logs", { length: logs.length })}
{init === 4
? `, ${t("filtered_logs", { length: queryLogs.length })}`
: `, ${t("enter_filter_conditions")}`}
</Text>
<List
style={{
height: "100%",
overflow: "auto",
}}
size="small"
dataSource={queryLogs}
render={(item: Logger, index) => (
<List.Item
key={index}
style={{
background:
// eslint-disable-next-line no-nested-ternary
item.level === "error"
? "var(--color-danger-light-2)" // eslint-disable-next-line no-nested-ternary
: item.level === "warn"
? "var(--color-warning-light-2)"
: item.level === "info"
? "var(--color-success-light-2)"
: "var(--color-primary-light-1)",
}}
>
{formatUnixTime(item.createtime / 1000)}{" "}
{typeof item.message === "object"
? JSON.stringify(item.message)
: item.message}{" "}
{JSON.stringify(item.label)}
</List.Item>
)}
/>
</Card>
</Space>
</div>
</>
);
}
export default LoggerPage;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,269 @@
import React, { useState } from "react";
import {
Button,
Card,
Checkbox,
Input,
Message,
Select,
Space,
} from "@arco-design/web-react";
import FileSystemParams from "@App/pages/components/FileSystemParams";
import { SystemConfig } from "@App/pkg/config/config";
import IoC from "@App/app/ioc";
import FileSystemFactory, { FileSystemType } from "@Pkg/filesystem/factory";
import Title from "@arco-design/web-react/es/Typography/title";
import { IconQuestionCircleFill } from "@arco-design/web-react/icon";
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-import-module-exports
import { format } from "prettier";
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-import-module-exports
import babel from "prettier/parser-babel";
import GMApiSetting from "@App/pages/components/GMApiSetting";
import i18n from "@App/locales/locales";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import Logger from "@App/app/logger/logger";
function Setting() {
const systemConfig = IoC.instance(SystemConfig) as SystemConfig;
const [syncDelete, setSyncDelete] = useState<boolean>(
systemConfig.cloudSync.syncDelete
);
const [enableCloudSync, setEnableCloudSync] = useState(
systemConfig.cloudSync.enable
);
const [fileSystemType, setFilesystemType] = useState<FileSystemType>(
systemConfig.cloudSync.filesystem
);
const [fileSystemParams, setFilesystemParam] = useState<{
[key: string]: any;
}>(systemConfig.cloudSync.params[fileSystemType] || {});
const [language, setLanguage] = useState(i18n.language);
const languageList: { key: string; title: string }[] = [];
const { t } = useTranslation();
Object.keys(i18n.store.data).forEach((key) => {
if (key === "ach-UG") {
return;
}
languageList.push({
key,
title: i18n.store.data[key].title as string,
});
});
languageList.push({
key: "help",
title: t("help_translate"),
});
return (
<Space
className="setting"
direction="vertical"
style={{
width: "100%",
height: "100%",
overflow: "auto",
position: "relative",
}}
>
<Card title={t("general")} bordered={false}>
<Space direction="vertical">
<Space>
<span>{t("language")}:</span>
<Select
value={language}
className="w-24"
onChange={(value) => {
if (value === "help") {
window.open(
"https://crowdin.com/project/scriptcat",
"_blank"
);
return;
}
setLanguage(value);
i18n.changeLanguage(value);
dayjs.locale(value.toLocaleLowerCase());
localStorage.language = value;
Message.success(t("language_change_tip")!);
}}
>
{languageList.map((item) => (
<Select.Option key={item.key} value={item.key}>
{item.title}
</Select.Option>
))}
</Select>
</Space>
<Space>
{t("menu_expand_num_before")}:
<Input
style={{ width: "64px" }}
type="number"
defaultValue={systemConfig.menuExpandNum.toString()}
onChange={(val) => {
systemConfig.menuExpandNum = parseInt(val, 10);
}}
/>
{t("menu_expand_num_after")}
</Space>
</Space>
</Card>
<Card className="sync" title={t("script_sync")} bordered={false}>
<Space direction="vertical">
<Checkbox
checked={syncDelete}
onChange={(checked) => {
setSyncDelete(checked);
}}
>
{t("sync_delete")}
</Checkbox>
<FileSystemParams
preNode={
<Checkbox
checked={enableCloudSync}
onChange={(checked) => {
setEnableCloudSync(checked);
}}
>
{t("enable_script_sync_to")}
</Checkbox>
}
actionButton={[
<Button
key="save"
type="primary"
onClick={async () => {
// Save to the configuration
// Perform validation if enabled
if (enableCloudSync) {
Message.info(t("cloud_sync_account_verification")!);
try {
await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
} catch (e) {
Message.error(
`${t(
"cloud_sync_verification_failed"
)}: ${JSON.stringify(Logger.E(e))}`
);
return;
}
}
const params = { ...systemConfig.backup.params };
params[fileSystemType] = fileSystemParams;
systemConfig.cloudSync = {
enable: enableCloudSync,
syncDelete,
filesystem: fileSystemType,
params,
};
Message.success(t("save_success")!);
}}
>
{t("save")}
</Button>,
]}
fileSystemType={fileSystemType}
fileSystemParams={fileSystemParams}
onChangeFileSystemType={(type) => {
setFilesystemType(type);
}}
onChangeFileSystemParams={(params) => {
setFilesystemParam(params);
}}
/>
</Space>
</Card>
<Card title={t("update")} bordered={false}>
<Space direction="vertical">
<Space>
<span>{t("script_subscription_check_interval")}:</span>
<Select
defaultValue={systemConfig.checkScriptUpdateCycle.toString()}
style={{
width: 120,
}}
onChange={(value) => {
systemConfig.checkScriptUpdateCycle = parseInt(value, 10);
}}
>
<Select.Option value="0">{t("never")}</Select.Option>
<Select.Option value="21600">{t("6_hours")}</Select.Option>
<Select.Option value="43200">{t("12_hours")}</Select.Option>
<Select.Option value="86400">{t("every_day")}</Select.Option>
<Select.Option value="604800">{t("every_week")}</Select.Option>
</Select>
</Space>
<Checkbox
onChange={(checked) => {
systemConfig.updateDisableScript = checked;
}}
defaultChecked={systemConfig.updateDisableScript}
>
{t("update_disabled_scripts")}
</Checkbox>
<Checkbox
onChange={(checked) => {
systemConfig.silenceUpdateScript = checked;
}}
defaultChecked={systemConfig.silenceUpdateScript}
>
{t("silent_update_non_critical_changes")}
</Checkbox>
</Space>
</Card>
<GMApiSetting />
<Card title="ESLint" bordered={false}>
<Space direction="vertical" className="w-full">
<Checkbox
onChange={(checked) => {
systemConfig.enableEslint = checked;
}}
defaultChecked={systemConfig.enableEslint}
>
{t("enable_eslint")}
</Checkbox>
<Title heading={5}>
{t("eslint_rules")}{" "}
<Button
type="text"
style={{
height: 24,
}}
icon={
<IconQuestionCircleFill
style={{
margin: 0,
}}
/>
}
href="https://eslint.org/play/"
target="_blank"
iconOnly
/>
</Title>
<Input.TextArea
placeholder={t("enter_eslint_rules")!}
autoSize={{
minRows: 4,
maxRows: 8,
}}
defaultValue={format(systemConfig.eslintConfig, {
parser: "json",
plugins: [babel],
})}
onBlur={(v) => {
systemConfig.eslintConfig = v.target.value;
}}
/>
</Space>
</Card>
</Space>
);
}
export default Setting;

View File

@ -0,0 +1,320 @@
import React, { useEffect, useRef, useState } from "react";
import Text from "@arco-design/web-react/es/Typography/text";
import {
Button,
Card,
Input,
Message,
Popconfirm,
Switch,
Table,
Tag,
Tooltip,
} from "@arco-design/web-react";
import {
Subscribe,
SUBSCRIBE_STATUS_DISABLE,
SUBSCRIBE_STATUS_ENABLE,
SubscribeDAO,
} from "@App/app/repo/subscribe";
import { ColumnProps } from "@arco-design/web-react/es/Table";
import IoC from "@App/app/ioc";
import SubscribeController from "@App/app/service/subscribe/controller";
import { IconSearch, IconUserAdd } from "@arco-design/web-react/icon";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
import { semTime } from "@App/pkg/utils/utils";
import { RiDeleteBin5Fill } from "react-icons/ri";
import { useTranslation } from "react-i18next"; // 添加了 react-i18next 的引用
type ListType = Subscribe & { loading?: boolean };
function SubscribeList() {
const dao = new SubscribeDAO();
const subscribeCtrl = IoC.instance(
SubscribeController
) as SubscribeController;
const [list, setList] = useState<ListType[]>([]);
const inputRef = useRef<RefInputType>(null);
const { t } = useTranslation(); // 使用 useTranslation hook
useEffect(() => {
dao.table
.orderBy("id")
.toArray()
.then((subscribes) => {
setList(subscribes);
});
}, []);
const columns: ColumnProps[] = [
{
title: "#",
dataIndex: "id",
width: 70,
key: "#",
sorter: (a, b) => a.id - b.id,
},
{
title: t("enable"),
width: t("subscribe_list_enable_width"),
key: "enable",
sorter(a, b) {
return a.status - b.status;
},
filters: [
{
text: t("enable"),
value: SUBSCRIBE_STATUS_ENABLE,
},
{
text: t("disable"),
value: SUBSCRIBE_STATUS_DISABLE,
},
],
onFilter: (value, row) => row.status === value,
render: (col, item: ListType, index) => {
return (
<Switch
checked={item.status === SUBSCRIBE_STATUS_ENABLE}
loading={item.loading}
disabled={item.loading}
onChange={(checked) => {
list[index].loading = true;
setList([...list]);
let p: Promise<any>;
if (checked) {
p = subscribeCtrl.enable(item.id).then(() => {
list[index].status = SUBSCRIBE_STATUS_ENABLE;
});
} else {
p = subscribeCtrl.disable(item.id).then(() => {
list[index].status = SUBSCRIBE_STATUS_DISABLE;
});
}
p.catch((err) => {
Message.error(err);
}).finally(() => {
list[index].loading = false;
setList([...list]);
});
}}
/>
);
},
},
{
title: t("name"),
dataIndex: "name",
sorter: (a, b) => a.name.localeCompare(b.name),
filterIcon: <IconSearch />,
key: "name",
// eslint-disable-next-line react/no-unstable-nested-components
filterDropdown: ({ filterKeys, setFilterKeys, confirm }: any) => {
return (
<div className="arco-table-custom-filter">
<Input.Search
ref={inputRef}
searchButton
placeholder={t("enter_subscribe_name")!}
value={filterKeys[0] || ""}
onChange={(value) => {
setFilterKeys(value ? [value] : []);
}}
onSearch={() => {
confirm();
}}
/>
</div>
);
},
onFilter: (value, row) => (value ? row.name.indexOf(value) !== -1 : true),
onFilterDropdownVisibleChange: (visible) => {
if (visible) {
setTimeout(() => inputRef.current!.focus(), 150);
}
},
className: "max-w-[240px]",
render: (col) => {
return (
<Tooltip content={col} position="tl">
<Text
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{col}
</Text>
</Tooltip>
);
},
},
{
title: t("version"),
dataIndex: "version",
width: 120,
align: "center",
key: "version",
render(col, item: Subscribe) {
return item.metadata.version && item.metadata.version[0];
},
},
{
title: t("permission"),
width: 120,
align: "center",
key: "permission",
render(_, item: Subscribe) {
if (item.metadata.connect) {
return <div />;
}
return (item.metadata.connect as string[]).map((val) => {
return (
<img
src={`https://${val}/favicon.ico`}
alt={val}
height={16}
width={16}
/>
);
});
},
},
{
title: t("source"),
width: 100,
align: "center",
key: "source",
render(_, item: Subscribe) {
return (
<Tooltip
content={
<p style={{ margin: 0, padding: 0 }}>
{t("subscribe_url")}: {decodeURIComponent(item.url)}
</p>
}
>
<Tag
icon={<IconUserAdd color="" />}
color="green"
bordered
style={{
cursor: "pointer",
}}
>
{t("subscribe_url")}
</Tag>
</Tooltip>
);
},
},
{
title: t("last_updated"),
dataIndex: "updatetime",
align: "center",
key: "updatetime",
width: t("script_list_last_updated_width"),
sorter: (a, b) => a.updatetime - b.updatetime,
render(col, subscribe: Subscribe) {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span
style={{
cursor: "pointer",
}}
onClick={() => {
Message.info({
id: "checkupdate",
content: t("checking_for_updates"),
});
subscribeCtrl
.checkUpdate(subscribe.id)
.then((res) => {
if (res) {
Message.warning({
id: "checkupdate",
content: t("new_version_available"),
});
} else {
Message.success({
id: "checkupdate",
content: t("latest_version"),
});
}
})
.catch((e) => {
Message.error({
id: "checkupdate",
content: `${t("check_update_failed")}: ${e.message}`,
});
});
}}
>
{semTime(new Date(col))}
</span>
);
},
},
{
title: t("action"),
width: 120,
align: "center",
key: "action",
render(_, item: Subscribe) {
return (
<Button.Group>
<Popconfirm
title={t("confirm_delete_subscription")}
icon={<RiDeleteBin5Fill />}
onOk={() => {
setList(list.filter((val) => val.id !== item.id));
subscribeCtrl.delete(item.id).catch((e) => {
Message.error(`${t("delete_failed")}: ${e}`);
});
}}
>
<Button
type="text"
icon={<RiDeleteBin5Fill />}
onClick={() => {}}
style={{
color: "var(--color-text-2)",
}}
/>
</Popconfirm>
</Button.Group>
);
},
},
];
return (
<Card
className="script-list subscribe-list"
style={{
height: "100%",
overflowY: "auto",
}}
>
<Table
className="arco-drag-table-container"
rowKey="id"
tableLayoutFixed
columns={columns}
data={list}
pagination={{
total: list.length,
pageSize: list.length,
hideOnSinglePage: true,
}}
style={{
minWidth: "1100px",
}}
/>
</Card>
);
}
export default SubscribeList;

View File

@ -0,0 +1,346 @@
import React, { useRef, useState } from "react";
import {
Button,
Card,
Checkbox,
Drawer,
Empty,
Input,
List,
Message,
Modal,
Space,
} from "@arco-design/web-react";
import Title from "@arco-design/web-react/es/Typography/title";
import IoC from "@App/app/ioc";
import SynchronizeController from "@App/app/service/synchronize/controller";
import FileSystemFactory, { FileSystemType } from "@Pkg/filesystem/factory";
import { SystemConfig } from "@App/pkg/config/config";
import { File, FileReader } from "@Pkg/filesystem/filesystem";
import { formatUnixTime } from "@App/pkg/utils/utils";
import FileSystemParams from "@App/pages/components/FileSystemParams";
import { IconQuestionCircleFill } from "@arco-design/web-react/icon";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
import { useTranslation } from "react-i18next";
import SystemController from "@App/app/service/system/controller";
function Tools() {
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
const syncCtrl = IoC.instance(SynchronizeController) as SynchronizeController;
const fileRef = useRef<HTMLInputElement>(null);
const systemConfig = IoC.instance(SystemConfig) as SystemConfig;
const [fileSystemType, setFilesystemType] = useState<FileSystemType>(
systemConfig.backup.filesystem
);
const [fileSystemParams, setFilesystemParam] = useState<{
[key: string]: any;
}>(systemConfig.backup.params[fileSystemType] || {});
const [backupFileList, setBackupFileList] = useState<File[]>([]);
const vscodeRef = useRef<RefInputType>(null);
const { t } = useTranslation();
return (
<Space
className="tools"
direction="vertical"
style={{
width: "100%",
height: "100%",
overflow: "auto",
position: "relative",
}}
>
<Card className="backup" title={t("backup")} bordered={false}>
<Space direction="vertical">
<Title heading={6}>{t("local")}</Title>
<Space>
<input
type="file"
ref={fileRef}
style={{ display: "none" }}
accept=".zip"
/>
<Button
type="primary"
loading={loading.local}
onClick={async () => {
setLoading((prev) => ({ ...prev, local: true }));
await syncCtrl.backup();
setLoading((prev) => ({ ...prev, local: false }));
}}
>
{t("export_file")}
</Button>
<Button
type="primary"
onClick={() => {
syncCtrl
.openImportFile(fileRef.current!)
.then(() => {
Message.success(t("select_import_script")!);
})
.then((e) => {
Message.error(`${t("import_error")}${e}`);
});
}}
>
{t("import_file")}
</Button>
</Space>
<Title heading={6}>{t("cloud")}</Title>
<FileSystemParams
preNode={t("backup_to")}
onChangeFileSystemType={(type) => {
setFilesystemType(type);
}}
onChangeFileSystemParams={(params) => {
setFilesystemParam(params);
}}
actionButton={[
<Button
key="backup"
type="primary"
loading={loading.cloud}
onClick={() => {
// Store parameters
const params = { ...systemConfig.backup.params };
params[fileSystemType] = fileSystemParams;
systemConfig.backup = {
filesystem: fileSystemType,
params,
};
setLoading((prev) => ({ ...prev, cloud: true }));
Message.info(t("preparing_backup")!);
syncCtrl
.backupToCloud(fileSystemType, fileSystemParams)
.then(() => {
Message.success(t("backup_success")!);
})
.catch((e) => {
Message.error(`${t("backup_failed")}: ${e}`);
})
.finally(() => {
setLoading((prev) => ({ ...prev, cloud: false }));
});
}}
>
{t("backup")}
</Button>,
<Button
key="list"
type="primary"
onClick={async () => {
let fs = await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
try {
fs = await fs.openDir("ScriptCat");
let list = await fs.list();
list.sort((a, b) => b.updatetime - a.updatetime);
// Filter non-zip files
list = list.filter((file) => file.name.endsWith(".zip"));
if (list.length === 0) {
Message.info(t("no_backup_files")!);
return;
}
setBackupFileList(list);
} catch (e) {
Message.error(`${t("get_backup_files_failed")}: ${e}`);
}
}}
>
{t("backup_list")}
</Button>,
]}
fileSystemType={fileSystemType}
fileSystemParams={fileSystemParams}
/>
<Drawer
width={400}
title={
<div className="flex flex-row justify-between w-full gap-10">
<span>{t("backup_list")}</span>
<Button
type="secondary"
size="mini"
onClick={async () => {
let fs = await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
try {
fs = await fs.openDir("ScriptCat");
const url = await fs.getDirUrl();
if (url) {
window.open(url, "_black");
}
} catch (e) {
Message.error(`${t("get_backup_dir_url_failed")}: ${e}`);
}
}}
>
{t("open_backup_dir")}
</Button>
</div>
}
visible={backupFileList.length !== 0}
onOk={() => {
setBackupFileList([]);
}}
onCancel={() => {
setBackupFileList([]);
}}
>
<List
bordered={false}
dataSource={backupFileList}
render={(item: File) => (
<List.Item key={item.name}>
<List.Item.Meta
title={item.name}
description={formatUnixTime(item.updatetime / 1000)}
/>
<Space className="w-full justify-end">
<Button
type="primary"
size="small"
onClick={async () => {
Message.info(t("pulling_data_from_cloud")!);
let fs = await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
let file: FileReader;
let data: Blob;
try {
fs = await fs.openDir("ScriptCat");
file = await fs.open(item);
data = (await file.read("blob")) as Blob;
} catch (e) {
Message.error(`${t("pull_failed")}: ${e}`);
return;
}
const url = URL.createObjectURL(data);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 100000);
syncCtrl
.openImportWindow(item.name, url)
.then(() => {
Message.success(t("select_import_script")!);
})
.then((e) => {
Message.error(`${t("import_error")}${e}`);
});
}}
>
{t("restore")}
</Button>
<Button
type="primary"
status="danger"
size="small"
onClick={() => {
Modal.confirm({
title: t("confirm_delete"),
content: `${t("confirm_delete_backup_file")}${
item.name
}?`,
onOk: async () => {
let fs = await FileSystemFactory.create(
fileSystemType,
fileSystemParams
);
try {
fs = await fs.openDir("ScriptCat");
await fs.delete(item.name);
setBackupFileList(
backupFileList.filter(
(i) => i.name !== item.name
)
);
Message.success(t("delete_success")!);
} catch (e) {
Message.error(`${t("delete_failed")}${e}`);
}
},
});
}}
>
{t("delete")}
</Button>
</Space>
</List.Item>
)}
/>
</Drawer>
<Title heading={6}>{t("backup_strategy")}</Title>
<Empty description={t("under_construction")} />
</Space>
</Card>
<Card
title={
<>
<span>{t("development_debugging")}</span>
<Button
type="text"
style={{
height: 24,
}}
icon={
<IconQuestionCircleFill
style={{
margin: 0,
}}
/>
}
href="https://www.bilibili.com/video/BV16q4y157CP"
target="_blank"
iconOnly
/>
</>
}
bordered={false}
>
<Space direction="vertical">
<Title heading={6}>{t("vscode_url")}</Title>
<Input
ref={vscodeRef}
defaultValue={systemConfig.vscodeUrl}
onChange={(value) => {
systemConfig.vscodeUrl = value;
}}
/>
<Checkbox
onChange={(checked) => {
systemConfig.vscodeReconnect = checked;
}}
defaultChecked={systemConfig.vscodeReconnect}
>
{t("auto_connect_vscode_service")}
</Checkbox>
<Button
type="primary"
onClick={() => {
const ctrl = IoC.instance(SystemController) as SystemController;
ctrl
.connectVSCode()
.then(() => {
Message.success(t("connection_success")!);
})
.catch((e) => {
Message.error(`${t("connection_failed")}: ${e}`);
});
}}
>
{t("connect")}
</Button>
</Space>
</Card>
</Space>
);
}
export default Tools;

View File

@ -0,0 +1,927 @@
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import CodeEditor from "@App/pages/components/CodeEditor";
import React, { useEffect, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { editor, KeyCode, KeyMod } from "monaco-editor";
import {
Button,
Dropdown,
Grid,
Menu,
Message,
Tabs,
Tooltip,
} from "@arco-design/web-react";
import TabPane from "@arco-design/web-react/es/Tabs/tab-pane";
import ScriptController from "@App/app/service/script/controller";
import normalTpl from "@App/template/normal.tpl";
import crontabTpl from "@App/template/crontab.tpl";
import backgroundTpl from "@App/template/background.tpl";
import { v4 as uuidv4 } from "uuid";
import "./index.css";
import IoC from "@App/app/ioc";
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { prepareScriptByCode } from "@App/pkg/utils/script";
import RuntimeController from "@App/runtime/content/runtime";
import ScriptStorage from "@App/pages/components/ScriptStorage";
import ScriptResource from "@App/pages/components/ScriptResource";
import ScriptSetting from "@App/pages/components/ScriptSetting";
const { Row } = Grid;
const { Col } = Grid;
// 声明一个Map存储Script
const ScriptMap = new Map();
type HotKey = {
hotKey: number;
action: (script: Script, codeEditor: editor.IStandaloneCodeEditor) => void;
};
const Editor: React.FC<{
id: string;
script: Script;
hotKeys: HotKey[];
callbackEditor: (e: editor.IStandaloneCodeEditor) => void;
onChange: (code: string) => void;
}> = ({ id, script, hotKeys, callbackEditor, onChange }) => {
const [init, setInit] = useState(false);
const codeEditor = useRef<{ editor: editor.IStandaloneCodeEditor }>(null);
// Script.uuid为keyScript为value储存Script
ScriptMap.has(script.uuid) || ScriptMap.set(script.uuid, script);
useEffect(() => {
if (!codeEditor.current || !codeEditor.current.editor) {
setTimeout(() => {
setInit(true);
}, 200);
return () => {};
}
// 初始化editor时将Script的uuid绑定到editor上
// @ts-ignore
if (!codeEditor.current.editor.uuid) {
// @ts-ignore
codeEditor.current.editor.uuid = script.uuid;
}
hotKeys.forEach((item) => {
codeEditor.current?.editor.addCommand(item.hotKey, () => {
// 获取当前激活的editor通过editor._focusTracker._hasFocus判断editor激活状态 可能有更好的方法)
const activeEditor = editor
.getEditors()
// @ts-ignore
// eslint-disable-next-line no-underscore-dangle
.find((i) => i._focusTracker._hasFocus);
// 仅在获取到激活的editor时通过editor上绑定的uuid获取Script并指定激活的editor执行快捷键action
activeEditor &&
// @ts-ignore
item.action(ScriptMap.get(activeEditor.uuid), activeEditor);
});
});
codeEditor.current.editor.onKeyUp(() => {
onChange(codeEditor.current?.editor.getValue() || "");
});
callbackEditor(codeEditor.current.editor);
return () => {};
}, [init]);
return (
<CodeEditor
id={id}
ref={codeEditor}
code={script.code}
diffCode=""
editable
/>
);
};
type EditorMenu = {
title: string;
tooltip?: string;
action?: (script: Script, e: editor.IStandaloneCodeEditor) => void;
items?: {
title: string;
tooltip?: string;
hotKey?: number;
hotKeyString?: string;
action: (script: Script, e: editor.IStandaloneCodeEditor) => void;
}[];
};
const emptyScript = async (template: string, hotKeys: any, target?: string) => {
let code = "";
switch (template) {
case "background":
code = backgroundTpl;
break;
case "crontab":
code = crontabTpl;
break;
default:
code = normalTpl;
if (target === "initial") {
const url = await new Promise<string>((resolve) => {
chrome.storage.local.get(["activeTabUrl"], (result) => {
chrome.storage.local.remove(["activeTabUrl"]);
if (result.activeTabUrl) {
resolve(result.activeTabUrl.url);
} else {
resolve("undefind");
}
});
});
code = code.replace("{{match}}", url);
}
break;
}
const prepareScript = await prepareScriptByCode(code, "", uuidv4());
const { script } = prepareScript;
return Promise.resolve({
script,
code: script.code,
active: true,
hotKeys,
isChanged: false,
});
};
type visibleItem = "scriptStorage" | "scriptSetting" | "scriptResource";
const popstate = () => {
// eslint-disable-next-line no-restricted-globals, no-alert
if (confirm("脚本已修改, 离开后会丢失修改, 是否继续?")) {
window.history.back();
window.removeEventListener("popstate", popstate);
} else {
window.history.pushState(null, "", window.location.href);
}
return false;
};
function ScriptEditor() {
const scriptDAO = new ScriptDAO();
const scriptCtrl = IoC.instance(ScriptController) as ScriptController;
const runtimeCtrl = IoC.instance(RuntimeController) as RuntimeController;
const template = useSearchParams()[0].get("template") || "";
const target = useSearchParams()[0].get("target") || "";
const navigate = useNavigate();
const [visible, setVisible] = useState<{ [key: string]: boolean }>({});
const [editors, setEditors] = useState<
{
script: Script;
code: string;
active: boolean;
hotKeys: HotKey[];
editor?: editor.IStandaloneCodeEditor;
isChanged: boolean;
}[]
>([]);
const [scriptList, setScriptList] = useState<Script[]>([]);
const [currentScript, setCurrentScript] = useState<Script>();
const [selectSciptButtonAndTab, setSelectSciptButtonAndTab] =
useState<string>("");
const [rightOperationTab, setRightOperationTab] = useState<{
key: string;
uuid: string;
selectSciptButtonAndTab: string;
}>();
const setShow = (key: visibleItem, show: boolean) => {
Object.keys(visible).forEach((k) => {
visible[k] = false;
});
visible[key] = show;
setVisible({ ...visible });
};
const { id } = useParams();
const save = (
script: Script,
e: editor.IStandaloneCodeEditor
): Promise<Script> => {
// 解析code生成新的script并更新
return new Promise((resolve) => {
prepareScriptByCode(e.getValue(), script.origin || "", script.uuid)
.then((prepareScript) => {
const newScript = prepareScript.script;
scriptCtrl.upsert(newScript).then(
() => {
if (!newScript.name) {
Message.warning("脚本name不可以设置为空");
return;
}
if (newScript.id === 0) {
Message.success("新建成功,请注意后台脚本不会默认开启");
// 保存的时候如何左侧没有脚本即新建
setScriptList((prev) => {
setSelectSciptButtonAndTab(newScript.uuid);
return [newScript, ...prev];
});
} else {
setScriptList((prev) => {
// eslint-disable-next-line no-shadow, array-callback-return
prev.map((script: Script) => {
if (script.uuid === newScript.uuid) {
script.name = newScript.name;
}
});
return [...prev];
});
Message.success("保存成功");
}
setEditors((prev) => {
for (let i = 0; i < prev.length; i += 1) {
if (prev[i].script.uuid === newScript.uuid) {
prev[i].code = newScript.code;
prev[i].isChanged = false;
prev[i].script.name = newScript.name;
break;
}
}
resolve(newScript);
return [...prev];
});
},
(err) => {
Message.error(`保存失败: ${err}`);
}
);
})
.catch((err) => {
Message.error(`错误的脚本代码: ${err}`);
});
});
};
const saveAs = (script: Script, e: editor.IStandaloneCodeEditor) => {
return new Promise<void>((resolve) => {
chrome.downloads.download(
{
url: URL.createObjectURL(
new Blob([e.getValue()], { type: "text/javascript" })
),
saveAs: true, // true直接弹出对话框false弹出下载选项
filename: `${script.name}.user.js`,
},
() => {
/*
chrome扩展api发生错误无法通过try/catch捕获api回调函数中访问chrome.runtime.lastError进行获取
var chrome.runtime.lastError: chrome.runtime.LastError | undefined
This will be defined during an API method callback if there was an error
*/
if (chrome.runtime.lastError) {
// eslint-disable-next-line no-console
console.log("另存为失败: ", chrome.runtime.lastError);
Message.error(`另存为失败: ${chrome.runtime.lastError.message}`);
} else {
Message.success("另存为成功");
}
resolve();
}
);
});
};
const menu: EditorMenu[] = [
{
title: "文件",
items: [
{
title: "保存",
hotKey: KeyMod.CtrlCmd | KeyCode.KeyS,
hotKeyString: "Ctrl+S",
action: save,
},
{
title: "另存为",
hotKey: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyS,
hotKeyString: "Ctrl+Shift+S",
action: saveAs,
},
],
},
{
title: "运行",
items: [
{
title: "调试",
hotKey: KeyMod.CtrlCmd | KeyCode.F5,
hotKeyString: "Ctrl+F5",
tooltip:
"只有后台脚本/定时脚本才能调试, 且调试模式下不对进行权限校验(例如@connect)",
action: async (script, e) => {
// 保存更新代码之后再调试
const newScript = await save(script, e);
Message.loading({
id: "debug_script",
content: "正在准备脚本资源...",
duration: 3000,
});
runtimeCtrl
.debugScript(newScript)
.then(() => {
Message.success({
id: "debug_script",
content: "构建成功, 可以打开开发者工具在控制台中查看输出",
duration: 3000,
});
})
.catch((err) => {
LoggerCore.getLogger(Logger.E(err)).debug("debug script error");
Message.error({
id: "debug_script",
content: `构建失败: ${err}`,
duration: 3000,
});
});
},
},
],
},
{
title: "工具",
items: [
{
title: "脚本储存",
tooltip: "可以管理脚本GM_value的储存数据",
action(script) {
setShow("scriptStorage", true);
setCurrentScript(script);
},
},
{
title: "脚本资源",
tooltip: "管理@resource,@require下载的资源",
action(script) {
setShow("scriptResource", true);
setCurrentScript(script);
},
},
],
},
{
title: "设置",
tooltip: "对脚本进行一些自定义设置",
action(script) {
setShow("scriptSetting", true);
setCurrentScript(script);
},
},
];
// 根据菜单生产快捷键
const hotKeys: HotKey[] = [];
let activeTab = "";
for (let i = 0; i < editors.length; i += 1) {
if (editors[i].active) {
activeTab = i.toString();
break;
}
}
menu.forEach((item) => {
item.items &&
item.items.forEach((menuItem) => {
if (menuItem.hotKey) {
hotKeys.push({
hotKey: menuItem.hotKey,
action: menuItem.action,
});
}
});
});
useEffect(() => {
scriptDAO.table
.orderBy("sort")
.toArray()
.then((scripts) => {
setScriptList(scripts);
// 如果有id则打开对应的脚本
if (id) {
const iId = parseInt(id, 10);
for (let i = 0; i < scripts.length; i += 1) {
if (scripts[i].id === iId) {
editors.push({
script: scripts[i],
code: scripts[i].code,
active: true,
hotKeys,
isChanged: false,
});
setSelectSciptButtonAndTab(scripts[i].uuid);
setEditors([...editors]);
break;
}
}
}
});
if (!id) {
emptyScript(template || "", hotKeys, target).then((e) => {
editors.push(e);
setEditors([...editors]);
});
}
}, []);
// 控制onbeforeunload
useEffect(() => {
let flag = false;
for (let i = 0; i < editors.length; i += 1) {
if (editors[i].isChanged) {
flag = true;
break;
}
}
if (flag) {
const beforeunload = () => {
return true;
};
window.onbeforeunload = beforeunload;
window.history.pushState(null, "", window.location.href);
window.addEventListener("popstate", popstate);
} else {
window.removeEventListener("popstate", popstate);
}
return () => {
window.onbeforeunload = null;
};
}, [editors]);
// 对tab点击右键进行的操作
useEffect(() => {
let newEditors = [];
let selectEditorIndex: number = 0;
// 1 关闭当前, 2关闭其它, 3关闭左侧, 4关闭右侧
if (rightOperationTab) {
// eslint-disable-next-line default-case
switch (rightOperationTab.key) {
case "1":
newEditors = editors.filter(
(item) => item.script.uuid !== rightOperationTab.uuid
);
if (newEditors.length > 0) {
// 还有的话,如果之前有选中的,那么我们还是选中之前的,如果没有选中的我们就选中第一个
if (
rightOperationTab.selectSciptButtonAndTab ===
rightOperationTab.uuid
) {
if (newEditors.length > 0) {
newEditors[0].active = true;
setSelectSciptButtonAndTab(newEditors[0].script.uuid);
}
} else {
setSelectSciptButtonAndTab(
rightOperationTab.selectSciptButtonAndTab
);
// 之前选中的tab
editors.filter((item) => {
if (
item.script.uuid === rightOperationTab.selectSciptButtonAndTab
) {
item.active = true;
} else {
item.active = false;
}
return (
item.script.uuid === rightOperationTab.selectSciptButtonAndTab
);
});
}
}
setEditors([...newEditors]);
break;
case "2":
newEditors = editors.filter(
(item) => item.script.uuid === rightOperationTab.uuid
);
setSelectSciptButtonAndTab(rightOperationTab.uuid);
setEditors([...newEditors]);
break;
case "3":
editors.map((item, index) => {
if (item.script.uuid === rightOperationTab.uuid) {
selectEditorIndex = index;
}
return null;
});
newEditors = editors.splice(selectEditorIndex);
setEditors([...newEditors]);
break;
case "4":
editors.map((item, index) => {
if (item.script.uuid === rightOperationTab.uuid) {
selectEditorIndex = index;
}
return null;
});
newEditors = editors.splice(0, selectEditorIndex + 1);
setEditors([...newEditors]);
}
}
}, [rightOperationTab]);
return (
<div
className="h-full flex flex-col"
style={{
position: "relative",
left: -10,
top: -10,
width: "calc(100% + 20px)",
height: "calc(100% + 20px)",
}}
>
<ScriptStorage
visible={visible.scriptStorage}
script={currentScript}
onOk={() => {
setShow("scriptStorage", false);
}}
onCancel={() => {
setShow("scriptStorage", false);
}}
/>
<ScriptResource
visible={visible.scriptResource}
script={currentScript}
onOk={() => {
setShow("scriptResource", false);
}}
onCancel={() => {
setShow("scriptResource", false);
}}
/>
<ScriptSetting
visible={visible.scriptSetting}
script={currentScript!}
onOk={() => {
setShow("scriptSetting", false);
}}
onCancel={() => {
setShow("scriptSetting", false);
}}
/>
<div
className="h-6"
style={{
borderBottom: "1px solid var(--color-neutral-3)",
background: "var(--color-secondary)",
}}
>
<div className="flex flex-row">
{menu.map((item, index) => {
if (!item.items) {
// 没有子菜单
return (
<Button
key={`m_${item.title}`}
size="mini"
onClick={() => {
setEditors((prev) => {
prev.forEach((e) => {
if (e.active) {
item.action && item.action(e.script, e.editor!);
}
});
return prev;
});
}}
>
{item.title}
</Button>
);
}
return (
<Dropdown
key={`d_${index.toString()}`}
droplist={
<Menu
style={{
padding: "0",
margin: "0",
borderRadius: "0",
}}
>
{item.items.map((menuItem, i) => {
const btn = (
<Button
style={{
width: "100%",
textAlign: "left",
alignSelf: "center",
verticalAlign: "middle",
}}
key={`sm_${menuItem.title}`}
size="mini"
onClick={() => {
setEditors((prev) => {
prev.forEach((e) => {
if (e.active) {
menuItem.action(e.script, e.editor!);
}
});
return prev;
});
}}
>
<div
style={{
minWidth: "70px",
float: "left",
fontSize: "14px",
}}
>
{menuItem.title}
</div>
<div
style={{
minWidth: "50px",
float: "left",
color: "rgb(165 165 165)",
fontSize: "12px",
lineHeight: "22px", // 不知道除此以外怎么垂直居中
}}
>
{menuItem.hotKeyString}
</div>
</Button>
);
return (
<Menu.Item
key={`m_${i.toString()}`}
style={{
height: "unset",
padding: "0",
lineHeight: "unset",
}}
>
{menuItem.tooltip ? (
<Tooltip
key={`m${i.toString()}`}
position="right"
content={menuItem.tooltip}
>
{btn}
</Tooltip>
) : (
btn
)}
</Menu.Item>
);
})}
</Menu>
}
trigger="click"
position="bl"
>
<Button key={`m_${item.title}`} size="mini">
{item.title}
</Button>
</Dropdown>
);
})}
</div>
</div>
<Row
className="flex flex-grow flex-1"
style={{
overflow: "hidden",
}}
>
<Col
span={4}
className="h-full"
style={{
overflow: "scroll",
}}
>
<div
className="flex flex-col"
style={{
backgroundColor: "var(--color-secondary)",
overflow: "hidden",
}}
>
<Button
className="text-left"
size="mini"
disabled
style={{
color: "var(--color-text-2)",
}}
>
</Button>
{scriptList.map((script) => (
<Button
key={`s_${script.uuid}`}
size="mini"
className="text-left"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor:
selectSciptButtonAndTab === script.uuid ? "gray" : "",
}}
onClick={() => {
setSelectSciptButtonAndTab(script.uuid);
// 如果已经打开则激活
let flag = false;
for (let i = 0; i < editors.length; i += 1) {
if (editors[i].script.uuid === script.uuid) {
editors[i].active = true;
flag = true;
} else {
editors[i].active = false;
}
}
if (!flag) {
// 如果没有打开则打开
editors.push({
script,
code: script.code,
active: true,
hotKeys,
isChanged: false,
});
}
setEditors([...editors]);
}}
>
{script.name}
</Button>
))}
</div>
</Col>
<Col span={20} className="flex! flex-col h-full">
<Tabs
editable
activeTab={activeTab}
className="edit-tabs"
type="card-gutter"
style={{
overflow: "inherit",
}}
onChange={(index: string) => {
editors.forEach((_, i) => {
if (i.toString() === index) {
setSelectSciptButtonAndTab(editors[i].script.uuid);
editors[i].active = true;
} else {
editors[i].active = false;
}
setEditors([...editors]);
});
}}
onAddTab={() => {
emptyScript(template || "", hotKeys).then((e) => {
setEditors((prev) => {
prev.forEach((item) => {
item.active = false;
});
setSelectSciptButtonAndTab(e.script.uuid);
prev.push(e);
return [...prev];
});
});
}}
onDeleteTab={(index: string) => {
// 处理删除
setEditors((prev) => {
const i = parseInt(index, 10);
if (prev[i].isChanged) {
// eslint-disable-next-line no-restricted-globals, no-alert
if (!confirm("脚本已修改, 关闭后会丢失修改, 是否继续?")) {
return prev;
}
}
if (prev.length === 1) {
// 如果是id打开的回退到列表
if (id) {
navigate("/");
return prev;
}
// 如果没有打开的了, 则打开一个空白的
emptyScript(template || "", hotKeys).then((e) => {
setEditors([e]);
});
return prev;
}
if (prev[i].active) {
// 如果关闭的是当前激活的, 则激活下一个
if (i === prev.length - 1) {
prev[i - 1].active = true;
setSelectSciptButtonAndTab(prev[i - 1].script.uuid);
} else {
prev[i + 1].active = true;
setSelectSciptButtonAndTab(prev[i - 1].script.uuid);
}
}
prev.splice(i, 1);
return [...prev];
});
}}
>
{editors.map((e, index) => (
<TabPane
destroyOnHide
key={index!.toString()}
title={
<Dropdown
trigger="contextMenu"
position="bl"
droplist={
<Menu
// eslint-disable-next-line no-shadow
onClickMenuItem={(key) => {
setRightOperationTab({
...rightOperationTab,
key,
uuid: e.script.uuid,
selectSciptButtonAndTab,
});
}}
>
<Menu.Item key="1"></Menu.Item>
<Menu.Item key="2"></Menu.Item>
<Menu.Item key="3"></Menu.Item>
<Menu.Item key="4"></Menu.Item>
</Menu>
}
>
<span
style={{
// eslint-disable-next-line no-nested-ternary
color: e.isChanged
? "rgb(var(--orange-5))" // eslint-disable-next-line no-nested-ternary
: e.script.uuid === selectSciptButtonAndTab
? "rgb(var(--green-7))"
: e.active
? "rgb(var(--green-7))"
: "var(--color-text-1)",
}}
>
{e.script.name}
</span>
</Dropdown>
}
/>
))}
</Tabs>
<div className="flex flex-grow flex-1">
{editors.map((item) => {
// 先这样吧
setTimeout(() => {
if (item.active && item.editor) {
item.editor.focus();
}
}, 100);
return (
<div
className="w-full"
key={`fe_${item.script.uuid}`}
style={{
display: item.active ? "block" : "none",
}}
>
<Editor
id={`e_${item.script.uuid}`}
script={item.script}
hotKeys={item.hotKeys}
callbackEditor={(e) => {
setEditors((prev) => {
prev.forEach((v) => {
if (v.script.uuid === item.script.uuid) {
v.editor = e;
}
});
return [...prev];
});
}}
onChange={(code) => {
const isChanged = !(item.code === code);
if (isChanged !== item.isChanged) {
setEditors((prev) => {
prev.forEach((v) => {
if (v.script.uuid === item.script.uuid) {
v.isChanged = isChanged;
}
});
return [...prev];
});
}
}}
/>
</div>
);
})}
</div>
</Col>
</Row>
</div>
);
}
export default ScriptEditor;

View File

@ -0,0 +1,6 @@
.edit-tabs .arco-tabs-header-title-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,223 @@
/* eslint-disable import/prefer-default-export */
import React from "react";
import IoC from "@App/app/ioc";
import { Metadata, Script, ScriptDAO } from "@App/app/repo/scripts";
import ValueManager from "@App/app/service/value/manager";
import { Avatar, Button, Space, Tooltip } from "@arco-design/web-react";
import {
IconBug,
IconCode,
IconGithub,
IconHome,
} from "@arco-design/web-react/icon";
import { useTranslation } from "react-i18next";
// 较对脚本排序位置
export function scriptListSort(result: Script[]) {
const dao = new ScriptDAO();
for (let i = 0; i < result.length; i += 1) {
if (result[i].sort !== i) {
dao.update(result[i].id, { sort: i });
result[i].sort = i;
}
}
}
// 安装url转home主页
export function installUrlToHome(installUrl: string) {
try {
// 解析scriptcat
if (installUrl.indexOf("scriptcat.org") !== -1) {
const id = installUrl.split("/")[5];
return (
<Button
type="text"
iconOnly
size="small"
target="_blank"
href={`https://scriptcat.org/script-show-page/${id}`}
>
<img width={16} height={16} src="/assets/logo.png" alt="" />
</Button>
);
}
if (installUrl.indexOf("greasyfork.org") !== -1) {
const id = installUrl.split("/")[4];
return (
<Button
type="text"
iconOnly
size="small"
target="_blank"
href={`https://greasyfork.org/scripts/${id}`}
>
<img width={16} height={16} src="/assets/logo/gf.png" alt="" />
</Button>
);
}
if (installUrl.indexOf("raw.githubusercontent.com") !== -1) {
const repo = `${installUrl.split("/")[3]}/${installUrl.split("/")[4]}`;
return (
<Button
type="text"
iconOnly
size="small"
target="_blank"
href={`https://github.com/${repo}`}
style={{
color: "var(--color-text-1)",
}}
icon={<IconGithub />}
/>
);
}
if (installUrl.indexOf("github.com") !== -1) {
const repo = `${installUrl.split("/")[3]}/${installUrl.split("/")[4]}`;
return (
<Button
type="text"
iconOnly
size="small"
target="_blank"
href={`https://github.com/${repo}`}
style={{
color: "var(--color-text-1)",
}}
icon={<IconGithub />}
/>
);
}
} catch (e) {
// ignore error
}
return undefined;
}
export function ListHomeRender({ script }: { script: Script }) {
const { t } = useTranslation();
let home;
if (!script.metadata.homepageurl) {
home = installUrlToHome(script.downloadUrl || "");
}
return (
<Space size="mini">
{home && <Tooltip content={t("homepage")}>{home}</Tooltip>}
{script.metadata.homepage && (
<Tooltip content={t("homepage")}>
<Button
type="text"
iconOnly
icon={<IconHome />}
size="small"
href={script.metadata.homepage[0]}
target="_blank"
/>
</Tooltip>
)}
{script.metadata.homepageurl && (
<Tooltip content={t("homepage")}>
<Button
type="text"
iconOnly
icon={<IconHome />}
size="small"
href={script.metadata.homepageurl[0]}
target="_blank"
/>
</Tooltip>
)}
{script.metadata.website && (
<Tooltip content={t("script_website")}>
<Button
type="text"
iconOnly
icon={<IconHome />}
size="small"
href={script.metadata.website[0]}
target="_blank"
/>
</Tooltip>
)}
{script.metadata.source && (
<Tooltip content={t("script_source")}>
<Button
type="text"
iconOnly
icon={<IconCode />}
size="small"
href={script.metadata.source[0]}
target="_blank"
/>
</Tooltip>
)}
{script.metadata.supporturl && (
<Tooltip content={t("bug_feedback_script_support")}>
<Button
type="text"
iconOnly
icon={<IconBug />}
size="small"
href={script.metadata.supporturl[0]}
target="_blank"
/>
</Tooltip>
)}
</Space>
);
}
export function getValues(script: Script) {
const { config } = script;
return (IoC.instance(ValueManager) as ValueManager)
.getValues(script)
.then((data) => {
const newValues: { [key: string]: any } = {};
Object.keys(config!).forEach((tabKey) => {
const tab = config![tabKey];
Object.keys(tab).forEach((key) => {
// 动态变量
if (tab[key].bind) {
const bindKey = tab[key].bind!.substring(1);
newValues[bindKey] =
data[bindKey] === undefined ? undefined : data[bindKey].value;
}
newValues[`${tabKey}.${key}`] =
data[`${tabKey}.${key}`] === undefined
? config![tabKey][key].default
: data[`${tabKey}.${key}`].value;
});
});
return newValues;
});
}
export type ScriptIconsProps = {
script: { name: string; metadata: Metadata };
size?: number;
style?: React.CSSProperties;
};
export function ScriptIcons({ script, size = 32, style }: ScriptIconsProps) {
style = style || {};
style.display = style.display || "inline-block";
style.marginRight = style.marginRight || "8px";
let icon = "";
if (script.metadata.icon) {
[icon] = script.metadata.icon;
} else if (script.metadata.iconurl) {
[icon] = script.metadata.iconurl;
} else if (script.metadata.icon64) {
[icon] = script.metadata.icon64;
} else if (script.metadata.icon64url) {
[icon] = script.metadata.icon64url;
}
if (icon) {
return (
<Avatar size={size || 32} shape="square" style={style}>
<img src={icon} alt={script?.name} />
</Avatar>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}

View File

@ -11,10 +11,10 @@ import {
ScriptDAO, ScriptDAO,
UserConfig, UserConfig,
} from "@App/app/repo/scripts"; } from "@App/app/repo/scripts";
import { InstallSource } from "@App/app/service/service_worker";
import YAML from "yaml"; import YAML from "yaml";
import { Subscribe, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe"; import { Subscribe, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe";
import { nextTime } from "./utils"; import { nextTime } from "./utils";
import { InstallSource } from "@App/app/service/service_worker";
export function getMetadataStr(code: string): string | null { export function getMetadataStr(code: string): string | null {
const start = code.indexOf("==UserScript=="); const start = code.indexOf("==UserScript==");
@ -95,16 +95,16 @@ export type ScriptInfo = {
url: string; url: string;
code: string; code: string;
uuid: string; uuid: string;
isSubscribe: boolean; userSubscribe: boolean;
isUpdate: boolean;
metadata: Metadata; metadata: Metadata;
update: boolean;
source: InstallSource; source: InstallSource;
}; };
export async function fetchScriptInfo( export async function fetchScriptInfo(
url: string, url: string,
source: InstallSource, source: InstallSource,
isUpdate: boolean, update: boolean,
uuid: string uuid: string
): Promise<ScriptInfo> { ): Promise<ScriptInfo> {
const resp = await fetch(url, { const resp = await fetch(url, {
@ -127,15 +127,12 @@ export async function fetchScriptInfo(
const ret: ScriptInfo = { const ret: ScriptInfo = {
url, url,
code: body, code: body,
uuid,
isSubscribe: false,
isUpdate,
metadata: parse,
source, source,
update,
uuid,
userSubscribe: parse.usersubscribe !== undefined,
metadata: parse,
}; };
if (parse.usersubscribe) {
ret.isSubscribe = true;
}
return ret; return ret;
} }

View File

@ -182,3 +182,7 @@ export function openInCurrentTab(url: string) {
} }
); );
} }
export function isDebug() {
return process.env.NODE_ENV === "development";
}