Compare commits

..

24 Commits

Author SHA1 Message Date
ec28795dbb 脚本开启总开关 2025-04-29 14:14:10 +08:00
44041f4735 打开文档页面/更新日志 2025-04-29 11:57:16 +08:00
ffabe268b1 修复匹配问题与优化批量开启速度 2025-04-29 11:53:59 +08:00
ddd3219bae 更大范围的脚本匹配 2025-04-29 11:28:01 +08:00
14baa176d9 🐛 修复隐藏排序问题 #317 2025-04-29 10:42:29 +08:00
3c1e30182f 优化打包体积 2025-04-29 10:25:15 +08:00
1aaf1bbd4a 修复首次打开浏览器加载脚本的问题 2025-04-28 23:24:11 +08:00
8a216933ca vscode reconnect 2025-04-28 18:04:20 +08:00
51fe2a89e1 开启开发者模式引导 2025-04-28 15:20:26 +08:00
a26f1c5014 优化细节 2025-04-27 18:02:57 +08:00
e1a890a400 处理gm log和新建脚本问题 2025-04-25 16:44:00 +08:00
79e8b8869a 脚本设置 2025-04-25 15:41:02 +08:00
d761c62500 value设置 2025-04-24 22:48:22 +08:00
d200809fee temp 2025-04-24 18:05:12 +08:00
67ba515b2c 数据迁移 2025-04-24 17:22:02 +08:00
d9fdded7fb 数据迁移 2025-04-23 18:07:46 +08:00
498d36567b todo: 优化加载的脚本资源保存 2025-04-22 18:01:20 +08:00
d7adffcd9f 脚本订阅功能 2025-04-22 17:42:54 +08:00
44066d9543 升级版本 2025-04-22 10:40:05 +08:00
9a53c4e4e9 处理备份列表报错问题 2025-04-22 00:28:05 +08:00
1de1ba6373 云同步功能 2025-04-21 18:02:35 +08:00
185ba6e5cc 云同步配置 2025-04-18 18:01:05 +08:00
07c4518cba 导入导出 2025-04-17 00:58:08 +08:00
e2832093f0 synchronize服务 2025-04-16 18:01:52 +08:00
69 changed files with 3337 additions and 836 deletions

View File

@ -3,11 +3,8 @@ name: test
on:
push:
branches:
- main
- release/*
- dev
- develop/*
pull_request:
- disable # 暂时禁用
# pull_request:
jobs:
tests:

31
.gitignore vendored
View File

@ -1,16 +1,31 @@
# Local
.DS_Store
*.local
*.log*
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dist
node_modules
dist/
dist
dist-ssr
*.local
# IDE
.vscode/*
# Editor directories and files
.vscode
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
coverage
CHANGELOG.md
tailwind.config.js
.env

View File

@ -0,0 +1,34 @@
// ==UserScript==
// @name gm value
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description 可以持久化存储数据, 并且可以监听数据变化
// @author You
// @match https://bbs.tampermonkey.net.cn/
// @run-at document-start
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant GM_listValues
// @grant GM_deleteValue
// @grant GM_cookie
// ==/UserScript==
GM_addValueChangeListener("test_set", function (name, oldval, newval, remote) {
console.log("test_set change", name, oldval, newval, remote);
});
setInterval(() => {
console.log(GM_getValue("test_set"));
console.log(GM_listValues());
}, 2000);
setTimeout(() => {
GM_deleteValue("test_set");
}, 3000);
GM_setValue("test_set", new Date().getTime());
console.log(GM_getValue("test_set2"));
GM_setValue("test_set2", new Date().getTime());

View File

@ -1,6 +1,6 @@
{
"name": "scriptcat",
"version": "0.17.0-alpha.1",
"version": "0.17.0-alpha.4",
"description": "脚本猫,一个可以执行用户脚本的浏览器扩展,万物皆可脚本化,让你的浏览器可以做更多的事情!",
"author": "CodFrm",
"license": "GPLv3",

View File

@ -1,5 +1,3 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable import/prefer-default-export */
import { calculateMd5 } from "@App/pkg/utils/utils";
import { MD5 } from "crypto-js";
import { File, FileReader, FileWriter } from "../filesystem";

View File

@ -1,9 +1,5 @@
import JSZip from "jszip";
import FileSystem, {
File,
FileReader,
FileWriter,
} from "@Pkg/filesystem/filesystem";
import FileSystem, { File, FileReader, FileWriter } from "@Packages/filesystem/filesystem";
import { ZipFileReader, ZipFileWriter } from "./rw";
export default class ZipFileSystem implements FileSystem {

View File

@ -1,14 +1,21 @@
import LoggerCore from "@App/app/logger/core";
import { MessageConnect, MessageSend } from "./server";
import Logger from "@App/app/logger/logger";
export async function sendMessage(msg: MessageSend, action: string, data?: any): Promise<any> {
const res = await msg.sendMessage({ action, data });
LoggerCore.getInstance().logger().trace("sendMessage", { action, data, response: res });
const logger = LoggerCore.getInstance().logger().with({ action, data, response: res });
logger.trace("sendMessage");
if (res && res.code) {
console.error(res);
throw res.message;
} else {
return res.data;
try {
return res.data;
} catch (e) {
logger.trace("Invalid response data", Logger.E(e));
return undefined;
}
}
}

View File

@ -123,9 +123,16 @@ export class ExtensionContentMessageSend extends ExtensionMessageSend {
sendMessage(data: any): Promise<any> {
return new Promise((resolve) => {
chrome.tabs.sendMessage(this.tabId, data, this.options || {}, (resp) => {
resolve(resp);
});
if (!this.options?.documentId || this.options?.frameId) {
// 发送给指定的tab
chrome.tabs.sendMessage(this.tabId, data, (resp) => {
resolve(resp);
});
} else {
chrome.tabs.sendMessage(this.tabId, data, this.options, (resp) => {
resolve(resp);
});
}
});
}

View File

@ -36,6 +36,7 @@ export default defineConfig({
popup: `${src}/pages/popup/main.tsx`,
install: `${src}/pages/install/main.tsx`,
confirm: `${src}/pages/confirm/main.tsx`,
import: `${src}/pages/import/main.tsx`,
options: `${src}/pages/options/main.tsx`,
"editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js",
"ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker.js",
@ -152,7 +153,6 @@ export default defineConfig({
minify: true,
chunks: ["install"],
}),
,
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/confirm.html`,
template: `${src}/pages/template.html`,
@ -161,6 +161,14 @@ export default defineConfig({
minify: true,
chunks: ["confirm"],
}),
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/import.html`,
template: `${src}/pages/template.html`,
inject: "head",
title: "Import - ScriptCat",
minify: true,
chunks: ["import"],
}),
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/options.html`,
template: `${src}/pages/options.html`,
@ -200,6 +208,16 @@ export default defineConfig({
minimizerOptions: { targets },
}),
],
splitChunks: {
chunks: (chunk) => {
// 排除这些文件,不进行分离
return !["editor.worker", "ts.worker", "linter.worker", "service_worker", "content", "inject"].includes(
chunk.name || ""
);
},
minSize: 307200,
maxSize: 4194304,
},
},
experiments: {
css: true,

View File

@ -1,4 +1,4 @@
import { ConfirmParam } from "@App/runtime/service_worker/permission_verify";
import { ConfirmParam } from "./service/service_worker/permission_verify";
export default class CacheKey {
// 加载脚本信息时的缓存
@ -9,4 +9,9 @@ export default class CacheKey {
static permissionConfirm(scriptUuid: string, confirm: ConfirmParam): string {
return `permission:${scriptUuid}:${confirm.permissionValue || ""}:${confirm.permission || ""}`;
}
// importFile 导入文件
static importFile(uuid: string): string {
return `importFile:${uuid}`;
}
}

View File

@ -5,13 +5,16 @@ import { MessageSend } from "@Packages/message/server";
export default class MessageWriter implements Writer {
send: MessageSend;
constructor(connect: MessageSend) {
constructor(
connect: MessageSend,
private action: string = "logger"
) {
this.send = connect;
}
write(level: LogLevel, message: string, label: LogLabel): void {
this.send.sendMessage({
action: "logger",
action: this.action,
data: {
id: 0,
level,

View File

@ -1,8 +1,12 @@
import { getStorageName } from "@App/pkg/utils/utils";
import { db } from "./repo/dao";
import { Script } from "./repo/scripts";
import { Script, ScriptAndCode, ScriptCodeDAO, ScriptDAO } from "./repo/scripts";
import { Subscribe, SubscribeDAO } from "./repo/subscribe";
import { Value, ValueDAO } from "./repo/value";
import { Permission, PermissionDAO } from "./repo/permission";
// 0.10.0重构,重命名字段,统一使用小峰驼
function renameField(): void {
function renameField() {
db.version(16)
.stores({
scripts:
@ -33,9 +37,196 @@ function renameField(): void {
export: "++id,&scriptId",
});
// 将脚本数据迁移到chrome.storage
// db.version(18)
// .stores({})
// .upgrade((tx) => {});
db.version(18).upgrade(() => {
// 默认使用的事务这里加个延时用db.open()打开数据库后,再执行
setTimeout(async () => {
try {
// 迁移脚本
const scripts = await db.table("scripts").toArray();
const scriptDAO = new ScriptDAO();
const scriptCodeDAO = new ScriptCodeDAO();
console.log("开始迁移脚本数据", scripts.length);
await Promise.all(
scripts.map(async (script: ScriptAndCode) => {
const {
uuid,
name,
namespace,
author,
originDomain,
subscribeUrl,
type,
sort,
status,
runStatus,
metadata,
createtime,
checktime,
code,
checkUpdateUrl,
downloadUrl,
selfMetadata,
config,
error,
updatetime,
lastruntime,
nextruntime,
} = script;
const s = await scriptDAO.save({
uuid,
name,
namespace,
author,
originDomain,
origin,
checkUpdateUrl,
downloadUrl,
metadata,
selfMetadata,
subscribeUrl,
config,
type,
status,
sort,
runStatus,
error,
createtime,
updatetime,
checktime,
lastruntime,
nextruntime,
});
return scriptCodeDAO
.save({
uuid: s.uuid,
code,
})
.catch((e) => {
console.log("脚本代码迁移失败", e);
return Promise.reject(e);
});
})
);
// 迁移订阅
const subscribe = await db.table("subscribe").toArray();
const subscribeDAO = new SubscribeDAO();
if (subscribe.length) {
await Promise.all(
subscribe.map((s: Subscribe) => {
const { url, name, code, author, scripts, metadata, status, createtime, updatetime, checktime } = s;
return subscribeDAO.save({
url,
name,
code,
author,
scripts,
metadata,
status,
createtime,
updatetime,
checktime,
});
})
);
}
console.log("订阅数据迁移完成", subscribe.length);
// 迁移value
interface MV2Value {
id: number;
scriptId: number;
storageName?: string;
key: string;
value: any;
createtime: number;
updatetime: number;
}
const values = await db.table("value").toArray();
const valueDAO = new ValueDAO();
const valueMap = new Map<string, Value>();
await Promise.all(
values.map((v: MV2Value) => {
const { scriptId, storageName, key, value, createtime } = v;
return db
.table("scripts")
.where("id")
.equals(scriptId)
.first((script: Script) => {
if (script) {
let data: { [key: string]: any } = {};
if (!valueMap.has(script.uuid)) {
valueMap.set(script.uuid, {
uuid: script.uuid,
storageName: getStorageName(script),
data: data,
createtime,
updatetime: 0,
});
} else {
data = valueMap.get(script.uuid)!.data;
}
data[key] = value;
}
});
})
);
// 保存到数据库
await Promise.all(
Array.from(valueMap.keys()).map((uuid) => {
const { storageName, data, createtime } = valueMap.get(uuid)!;
return valueDAO.save(storageName!, {
uuid,
storageName,
data,
createtime,
updatetime: 0,
});
})
);
console.log("脚本value数据迁移完成", values.length);
// 迁移permission
const permissions = await db.table("permission").toArray();
const permissionDAO = new PermissionDAO();
await Promise.all(
permissions.map((p: Permission & { scriptId: number }) => {
const { scriptId, permission, permissionValue, createtime, updatetime, allow } = p;
return db
.table("scripts")
.where("id")
.equals(scriptId)
.first((script: Script) => {
if (script) {
return permissionDAO.save({
uuid: script.uuid,
permission,
permissionValue,
createtime,
updatetime,
allow,
});
}
});
})
);
console.log("脚本permission数据迁移完成", permissions.length);
// 打开页面,告知数据储存+升级至了mv3重启一次扩展
setTimeout(async () => {
const scripts = await scriptDAO.all();
console.log("脚本数据迁移完成", scripts.length);
if (scripts.length > 0) {
chrome.tabs.create({
url: "https://docs.scriptcat.org/docs/change/v0.17/",
});
setTimeout(() => {
chrome.runtime.reload();
}, 1000);
}
}, 2000);
} catch (e) {
console.error("脚本数据迁移失败", e);
}
}, 200);
});
return db.open();
}
export default function migrate() {
@ -90,7 +281,8 @@ export default function migrate() {
value: "++id,scriptId,storageName,key,createtime",
})
.upgrade((tx) => {
tx.table("value")
return tx
.table("value")
.toCollection()
.modify((value) => {
if (value.namespace) {
@ -112,5 +304,5 @@ export default function migrate() {
permission: "++id,scriptId,[scriptId+permission+permissionValue],createtime,updatetime",
});
// 使用小峰驼统一命名规范
renameField();
return renameField();
}

View File

@ -9,7 +9,7 @@ export abstract class Repo<T> {
return this.prefix + key;
}
protected async _save(key: string, val: T):Promise<T> {
protected async _save(key: string, val: T): Promise<T> {
return new Promise((resolve) => {
const data = {
[this.joinKey(key)]: val,

View File

@ -12,7 +12,6 @@ export interface SubscribeScript {
}
export interface Subscribe {
id: number;
url: string;
name: string;
code: string;
@ -31,6 +30,10 @@ export class SubscribeDAO extends Repo<Subscribe> {
}
public findByUrl(url: string) {
return this.findOne((key, value) => value.url === url);
return this.get(url);
}
public save(val: Subscribe) {
return super._save(val.url, val);
}
}

View File

@ -1,3 +1,5 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { ScriptRunResouce } from "@App/app/repo/scripts";
import { Client, sendMessage } from "@Packages/message/client";
import { CustomEventMessage } from "@Packages/message/custom_event_message";
@ -21,6 +23,7 @@ export default class ContentRuntime {
// 转发给inject
return sendMessage(this.msg, "inject/runtime/valueUpdate", data);
});
forwardMessage("serviceWorker", "script/isInstalled", this.server, this.extSend);
forwardMessage(
"serviceWorker",
"runtime/gmApi",
@ -83,7 +86,17 @@ export default class ContentRuntime {
case "GM_log":
// 拦截GM_log打印到控制台
// 由于某些页面会处理掉console.log所以丢到这里来打印
console.log(...data.params);
switch (data.params.length) {
case 1:
console.log(data.params[0]);
break;
case 2:
console.log("[" + data.params[1] + "]", data.params[0]);
break;
case 3:
console.log("[" + data.params[1] + "]", data.params[0], data.params[2]);
break;
}
break;
}
return false;

View File

@ -159,10 +159,11 @@ export default class GMApi {
}
if (value === undefined) {
delete this.scriptRes.value[key];
return this.sendMessage("GM_setValue", [key]);
} else {
this.scriptRes.value[key] = value;
return this.sendMessage("GM_setValue", [key, value]);
}
return this.sendMessage("GM_setValue", [key, value]);
}
@GMContext.API({ depend: ["GM_setValue"] })
@ -391,7 +392,7 @@ export default class GMApi {
anonymous: details.anonymous,
user: details.user,
password: details.password,
maxRedirects: details.maxRedirects,
redirect: details.redirect,
};
if (!param.headers) {
param.headers = {};

View File

@ -4,6 +4,8 @@ import ExecScript, { ValueUpdateData } from "./exec_script";
import { addStyle, ScriptFunc } from "./utils";
import { getStorageName } from "@App/pkg/utils/utils";
import { EmitEventRequest } from "../service_worker/runtime";
import { ExternalWhitelist } from "@App/app/const";
import { sendMessage } from "@Packages/message/client";
export class InjectRuntime {
execList: ExecScript[] = [];
@ -44,6 +46,40 @@ export class InjectRuntime {
val.valueUpdate(data);
});
});
// 注入允许外部调用
this.externalMessage();
}
externalMessage() {
// 对外接口白名单
let msg = this.msg;
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 sendMessage(msg, "content/script/isInstalled", {
name,
namespace,
});
callback(resp);
},
};
(<{ external: { Tampermonkey: any } }>(<unknown>window)).external.Tampermonkey = (<
{ external: { Scriptcat: any } }
>(<unknown>window)).external.Scriptcat;
break;
}
}
}
execScript(script: ScriptRunResouce, scriptFunc: ScriptFunc) {

View File

@ -1,7 +1,7 @@
import { WindowMessage } from "@Packages/message/window_message";
import { SCRIPT_RUN_STATUS, ScriptRunResouce } from "@App/app/repo/scripts";
import { sendMessage } from "@Packages/message/client";
import { MessageSend } from "@Packages/message/server";
import { MessageSend } from "@Packages/message/server";
export function preparationSandbox(msg: WindowMessage) {
return sendMessage(msg, "offscreen/preparationSandbox");
@ -31,3 +31,7 @@ export function runScript(msg: MessageSend, data: ScriptRunResouce) {
export function stopScript(msg: MessageSend, uuid: string) {
return sendMessage(msg, "offscreen/script/stopScript", uuid);
}
export function createObjectURL(msg: MessageSend, data: Blob) {
return sendMessage(msg, "offscreen/createObjectURL", data);
}

View File

@ -20,6 +20,10 @@ export class OffscreenManager {
private serviceWorker = new ServiceWorkerClient(this.extensionMessage);
constructor(private extensionMessage:MessageSend) {
}
logger(data: Logger) {
const dao = new LoggerDAO();
dao.save(data);
@ -54,5 +58,14 @@ export class OffscreenManager {
const gmApi = new GMApi(this.windowServer.group("gmApi"));
gmApi.init();
this.windowServer.on("createObjectURL", (data: Blob) => {
console.log("createObjectURL", data);
const url = URL.createObjectURL(data);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000 * 60);
return Promise.resolve(url);
});
}
}

View File

@ -1,9 +1,11 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { Script, SCRIPT_RUN_STATUS } from "../repo/scripts";
import { InstallSource } from "./service_worker";
import { Subscribe } from "../repo/subscribe";
export function subscribeScriptInstall(
messageQueue: MessageQueue,
callback: (message: { script: Script; update: boolean }) => void
callback: (message: { script: Script; update: boolean; upsertBy: InstallSource }) => void
) {
return messageQueue.subscribe("installScript", callback);
}
@ -12,6 +14,17 @@ export function subscribeScriptDelete(messageQueue: MessageQueue, callback: (mes
return messageQueue.subscribe("deleteScript", callback);
}
export function subscribeSubscribeInstall(
messageQueue: MessageQueue,
callback: (message: { subscribe: Subscribe; update: boolean }) => void
) {
return messageQueue.subscribe("installSubscribe", callback);
}
export function publishSubscribeInstall(messageQueue: MessageQueue, message: { subscribe: Subscribe }) {
return messageQueue.publish("installSubscribe", message);
}
export type ScriptEnableCallbackValue = { uuid: string; enable: boolean };
export function subscribeScriptEnable(

View File

@ -5,6 +5,12 @@ import { Resource } from "@App/app/repo/resource";
import { MessageSend } from "@Packages/message/server";
import { ScriptMenu, ScriptMenuItem } from "./popup";
import PermissionVerify, { ConfirmParam, UserConfirm } from "./permission_verify";
import { FileSystemType } from "@Packages/filesystem/factory";
import { v4 as uuidv4 } from "uuid";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
import { Subscribe } from "@App/app/repo/subscribe";
import { Permission } from "@App/app/repo/permission";
export class ServiceWorkerClient extends Client {
constructor(msg: MessageSend) {
@ -54,6 +60,16 @@ export class ScriptClient extends Client {
return this.do("excludeUrl", { uuid, url, remove });
}
// 重置匹配项
resetMatch(uuid: string, match: string[] | undefined) {
return this.do("resetMatch", { uuid, match });
}
// 重置排除项
resetExclude(uuid: string, exclude: string[] | undefined) {
return this.do("resetExclude", { uuid, exclude });
}
requestCheckUpdate(uuid: string) {
return this.do("requestCheckUpdate", uuid);
}
@ -67,6 +83,10 @@ export class ResourceClient extends Client {
getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
return this.do("getScriptResources", script);
}
deleteResource(url: string) {
return this.do("deleteResource", url);
}
}
export class ValueClient extends Client {
@ -74,7 +94,7 @@ export class ValueClient extends Client {
super(msg, "serviceWorker/value");
}
getScriptValue(script: Script) {
getScriptValue(script: Script): Promise<{ [key: string]: any }> {
return this.do("getScriptValue", script);
}
@ -149,4 +169,73 @@ export class PermissionClient extends Client {
getPermissionInfo(uuid: string): ReturnType<PermissionVerify["getInfo"]> {
return this.do("getInfo", uuid);
}
deletePermission(uuid: string, permission: string, permissionValue: string) {
return this.do("deletePermission", { uuid, permission, permissionValue });
}
getScriptPermissions(uuid: string): ReturnType<PermissionVerify["getScriptPermissions"]> {
return this.do("getScriptPermissions", uuid);
}
addPermission(permission: Permission) {
return this.do("addPermission", permission);
}
resetPermission(uuid: string) {
return this.do("resetPermission", uuid);
}
}
export class SynchronizeClient extends Client {
constructor(msg: MessageSend) {
super(msg, "serviceWorker/synchronize");
}
export(uuids?: string[]) {
return this.do("export", uuids);
}
backupToCloud(type: FileSystemType, params: any) {
return this.do("backupToCloud", { type, params });
}
async openImportWindow(filename: string, file: File | Blob) {
// 打开导入窗口用cache实现数据交互
const url = URL.createObjectURL(file);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 1000);
const uuid = uuidv4();
await Cache.getInstance().set(CacheKey.importFile(uuid), {
filename: filename,
url: url,
});
// 打开导入窗口用cache实现数据交互
chrome.tabs.create({
url: `/src/import.html?uuid=${uuid}`,
});
}
}
export class SubscribeClient extends Client {
constructor(msg: MessageSend) {
super(msg, "serviceWorker/subscribe");
}
install(subscribe: Subscribe) {
return this.do("install", { subscribe });
}
delete(url: string) {
return this.do("delete", { url });
}
checkUpdate(url: string) {
return this.do("checkUpdate", { url });
}
enable(url: string, enable: boolean) {
return this.do("enable", { url, enable });
}
}

View File

@ -242,7 +242,7 @@ export default class GMApi {
@PermissionVerify.API()
async GM_setValue(request: Request, sender: GetSender) {
if (!request.params || request.params.length !== 2) {
if (!request.params || request.params.length < 1) {
throw new Error("param is failed");
}
const [key, value] = request.params;
@ -347,7 +347,7 @@ export default class GMApi {
const url = URL.createObjectURL(blob);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 6000);
}, 30 * 1000);
return { action: "onload", data: url };
} catch (e: any) {
return { action: "error", data: { code: 5, error: e.message } };

View File

@ -7,6 +7,10 @@ import { RuntimeService } from "./runtime";
import { ServiceWorkerMessageSend } from "@Packages/message/window_message";
import { PopupService } from "./popup";
import { SystemConfig } from "@App/pkg/config/config";
import { SynchronizeService } from "./synchronize";
import { SubscribeService } from "./subscribe";
import { ExtServer, ExtVersion } from "@App/app/const";
import { systemConfig } from "@App/pages/store/global";
export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode";
@ -24,6 +28,7 @@ export default class ServiceWorkerManager {
await this.sender.init();
this.mq.emit("preparationOffscreen", {});
});
this.sender.init();
const systemConfig = new SystemConfig(this.mq);
@ -32,11 +37,31 @@ export default class ServiceWorkerManager {
const value = new ValueService(this.api.group("value"), this.sender);
const script = new ScriptService(systemConfig, this.api.group("script"), this.mq, value, resource);
script.init();
const runtime = new RuntimeService(systemConfig, this.api.group("runtime"), this.sender, this.mq, value, script);
const runtime = new RuntimeService(
systemConfig,
this.api.group("runtime"),
this.sender,
this.mq,
value,
script,
resource
);
runtime.init();
const popup = new PopupService(this.api.group("popup"), this.mq, runtime);
popup.init();
value.init(runtime, popup);
const synchronize = new SynchronizeService(
this.sender,
this.api.group("synchronize"),
script,
value,
resource,
this.mq,
systemConfig
);
synchronize.init();
const subscribe = new SubscribeService(systemConfig, this.api.group("subscribe"), this.mq, script);
subscribe.init();
// 定时器处理
chrome.alarms.onAlarm.addListener((alarm) => {
@ -44,7 +69,68 @@ export default class ServiceWorkerManager {
case "checkScriptUpdate":
script.checkScriptUpdate();
break;
case "cloudSync":
// 进行一次云同步
systemConfig.getCloudSync().then((config) => {
synchronize.buildFileSystem(config).then((fs) => {
synchronize.syncOnce(fs);
});
});
break;
case "checkSubscribeUpdate":
subscribe.checkSubscribeUpdate();
break;
case "checkUpdate":
// 检查扩展更新
this.checkUpdate();
break;
}
});
// 8小时检查一次扩展更新
chrome.alarms.create("checkUpdate", {
delayInMinutes: 0,
periodInMinutes: 8 * 60,
});
// 监听配置变化
this.mq.subscribe("systemConfigChange", (msg) => {
console.log("systemConfigChange", msg);
switch (msg.key) {
case "cloud_sync": {
synchronize.cloudSyncConfigChange(msg.value);
break;
}
}
});
// 启动一次云同步
systemConfig.getCloudSync().then((config) => {
synchronize.cloudSyncConfigChange(config);
});
if (process.env.NODE_ENV === "production") {
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === "install") {
chrome.tabs.create({ url: "https://docs.scriptcat.org/" });
} else if (details.reason === "update") {
chrome.tabs.create({
url: `https://docs.scriptcat.org/docs/change/#${ExtVersion}`,
});
}
});
}
}
checkUpdate() {
fetch(`${ExtServer}api/v1/system/version?version=${ExtVersion}`)
.then((resp) => resp.json())
.then((resp: { data: { notice: string; version: string } }) => {
systemConfig.getCheckUpdate().then((items) => {
if (items.notice !== resp.data.notice) {
systemConfig.setCheckUpdate(Object.assign(resp.data, { isRead: false }));
} else {
systemConfig.setCheckUpdate(Object.assign(resp.data, { isRead: items.isRead }));
}
});
});
}
}

View File

@ -96,15 +96,6 @@ export default class PermissionVerify {
reject: (reason: any) => void;
}> = new Queue();
async removePermissionCache(uuid: string) {
// 先删除缓存
(await Cache.getInstance().list()).forEach((key) => {
if (key.startsWith(`permission:${uuid}:`)) {
Cache.getInstance().del(key);
}
});
}
private permissionDAO: PermissionDAO = new PermissionDAO();
constructor(private group: Group) {}
@ -310,9 +301,45 @@ export default class PermissionVerify {
return Promise.resolve({ script, confirm, likeNum });
}
async deletePermission(data: { uuid: string; permission: string; permissionValue: string }) {
const oldConfirm = await this.permissionDAO.findByKey(data.uuid, data.permission, data.permissionValue);
if (!oldConfirm) {
throw new Error("permission not found");
} else {
await this.permissionDAO.delete(this.permissionDAO.key(oldConfirm));
// 删除缓存
Cache.getInstance().del(CacheKey.permissionConfirm(data.uuid, oldConfirm));
}
}
getScriptPermissions(uuid: string) {
// 获取脚本的所有权限
return this.permissionDAO.find((key, item) => item.uuid === uuid);
}
// 添加权限
async addPermission(permission: Permission) {
await this.permissionDAO.save(permission);
Cache.getInstance().del(CacheKey.permissionConfirm(permission.uuid, permission));
}
// 重置权限
async resetPermission(uuid: string) {
// 删除所有权限
const permissions = await this.permissionDAO.find((key, item) => item.uuid === uuid);
permissions.forEach((item) => {
this.permissionDAO.delete(this.permissionDAO.key(item));
Cache.getInstance().del(CacheKey.permissionConfirm(uuid, item));
});
}
init() {
this.dealConfirmQueue();
this.group.on("confirm", this.userConfirm.bind(this));
this.group.on("getInfo", this.getInfo.bind(this));
this.group.on("deletePermission", this.deletePermission.bind(this));
this.group.on("getScriptPermissions", this.getScriptPermissions.bind(this));
this.group.on("addPermission", this.getInfo.bind(this));
this.group.on("resetPermission", this.resetPermission.bind(this));
}
}

View File

@ -36,12 +36,22 @@ export class ResourceService {
return Promise.resolve(undefined);
}
cache: Map<string, { [key: string]: Resource }> = new Map();
public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
return Promise.resolve({
// 优先从内存中获取
if (this.cache.has(script.uuid)) {
return Promise.resolve(this.cache.get(script.uuid) || {});
}
// 资源不存在,重新加载
const res = await Promise.resolve({
...((await this.getResourceByType(script, "require")) || {}),
...((await this.getResourceByType(script, "require-css")) || {}),
...((await this.getResourceByType(script, "resource")) || {}),
});
// 缓存到内存
this.cache.set(script.uuid, res);
return res;
}
async getResourceByType(script: Script, type: ResourceType): Promise<{ [key: string]: Resource }> {
@ -159,6 +169,8 @@ export class ResourceService {
}
public async addResource(url: string, uuid: string, type: ResourceType): Promise<Resource> {
// 删除缓存
this.cache.delete(uuid);
const u = this.parseUrl(url);
let result = await this.getResourceModel(u.url);
// 资源不存在,重新加载
@ -245,7 +257,7 @@ export class ResourceService {
return fetch(u.url)
.then(async (resp) => {
if (resp.status !== 200) {
throw new Error(`resource response status not 200:${resp.status}`);
throw new Error(`resource response status not 200: ${resp.status}`);
}
return {
data: await resp.blob(),
@ -293,7 +305,20 @@ export class ResourceService {
return { url: urls[0], hash };
}
async deleteResource(url: string) {
// 删除缓存
const res = await this.resourceDAO.get(url);
if (!res) {
throw new Error("resource not found");
}
Object.keys(res.link).forEach((key) => {
this.cache.delete(key);
});
return this.resourceDAO.delete(url);
}
init() {
this.group.on("getScriptResources", this.getScriptResources.bind(this));
this.group.on("deleteResource", this.deleteResource.bind(this));
}
}

View File

@ -1,4 +1,4 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { MessageQueue, Unsubscribe } from "@Packages/message/message_queue";
import { ExtMessageSender, GetSender, Group, MessageSend } from "@Packages/message/server";
import {
Script,
@ -15,19 +15,19 @@ import { subscribeScriptDelete, subscribeScriptEnable, subscribeScriptInstall }
import { ScriptService } from "./script";
import { runScript, stopScript } from "../offscreen/client";
import { getRunAt } from "./utils";
import { randomString } from "@App/pkg/utils/utils";
import { isUserScriptsAvailable, randomString } from "@App/pkg/utils/utils";
import Cache from "@App/app/cache";
import { dealPatternMatches, UrlMatch } from "@App/pkg/utils/match";
import { ExtensionContentMessageSend } from "@Packages/message/extension_message";
import { sendMessage } from "@Packages/message/client";
import { compileInjectScript } from "../content/utils";
import { PopupService } from "./popup";
import Logger from "@App/app/logger/logger";
import LoggerCore from "@App/app/logger/core";
import PermissionVerify from "./permission_verify";
import { SystemConfig } from "@App/pkg/config/config";
import { ResourceService } from "./resource";
import { LocalStorageDAO } from "@App/app/repo/localStorage";
// 为了优化性能存储到缓存时删除了codevalue
// 为了优化性能存储到缓存时删除了codevalue与resource
export interface ScriptMatchInfo extends ScriptRunResouce {
matches: string[];
excludeMatches: string[];
@ -48,13 +48,17 @@ export class RuntimeService {
scriptCustomizeMatch: UrlMatch<string> = new UrlMatch<string>();
scriptMatchCache: Map<string, ScriptMatchInfo> | null | undefined;
isEnableDeveloperMode = false;
isEnableUserscribe = true;
constructor(
private systemConfig: SystemConfig,
private group: Group,
private sender: MessageSend,
private mq: MessageQueue,
private value: ValueService,
private script: ScriptService
private script: ScriptService,
private resource: ResourceService
) {}
async init() {
@ -68,8 +72,35 @@ export class RuntimeService {
this.group.on("runScript", this.runScript.bind(this));
this.group.on("pageLoad", this.pageLoad.bind(this));
// 读取inject.js注入页面
this.registerInjectScript();
// 检查是否开启了开发者模式
this.isEnableDeveloperMode = isUserScriptsAvailable();
if (!this.isEnableDeveloperMode) {
// 未开启加上警告引导
// 判断是否首次
const localStorage = new LocalStorageDAO();
localStorage.get("firstShowDeveloperMode").then((res) => {
if (!res) {
localStorage.save({
key: "firstShowDeveloperMode",
value: true,
});
// 打开页面
chrome.tabs.create({
url: `https://docs.scriptcat.org/docs/use/open-dev/`,
});
}
});
chrome.action.setBadgeBackgroundColor({
color: "#ff8c00",
});
chrome.action.setBadgeTextColor({
color: "#ffffff",
});
chrome.action.setBadgeText({
text: "!",
});
}
// 监听脚本开启
subscribeScriptEnable(this.mq, async (data) => {
const script = await this.scriptDAO.getAndCode(data.uuid);
@ -80,6 +111,7 @@ export class RuntimeService {
// 如果是后台脚本, 在offscreen中进行处理
if (script.type === SCRIPT_TYPE_NORMAL) {
// 加载页面脚本
// 不管开没开启都要加载一次脚本信息
await this.loadPageScript(script);
if (!data.enable) {
await this.unregistryPageScript(script.uuid);
@ -102,6 +134,32 @@ export class RuntimeService {
this.deleteScriptMatch(uuid);
});
this.systemConfig.addListener("enable_script", (enable) => {
this.isEnableUserscribe = enable;
if (enable) {
this.registerUserscripts();
} else {
this.unregisterUserscripts();
}
});
// 检查是否开启
this.isEnableUserscribe = await this.systemConfig.getEnableScript();
if (this.isEnableUserscribe) {
this.registerUserscripts();
}
}
unsubscribe: Unsubscribe[] = [];
// 取消脚本注册
unregisterUserscripts() {
chrome.userScripts.unregister();
this.deleteMessageFlag();
}
async registerUserscripts() {
// 读取inject.js注入页面
this.registerInjectScript();
// 将开启的脚本发送一次enable消息
const scriptDao = new ScriptDAO();
const list = await scriptDao.all();
@ -130,6 +188,14 @@ export class RuntimeService {
});
}
deleteMessageFlag() {
return Cache.getInstance().del("scriptInjectMessageFlag");
}
getMessageFlag() {
return Cache.getInstance().get("scriptInjectMessageFlag");
}
// 给指定tab发送消息
sendMessageToTab(to: ExtMessageSender, action: string, data: any) {
if (to.tabId === -1) {
@ -196,33 +262,36 @@ export class RuntimeService {
// 匹配当前页面的脚本
const matchScriptUuid = await this.getPageScriptUuidByUrl(chromeSender.url!);
const scripts = await Promise.all(
matchScriptUuid.map(async (uuid): Promise<undefined | ScriptRunResouce> => {
const scriptRes = Object.assign({}, this.scriptMatchCache?.get(uuid));
// 判断脚本是否开启
if (scriptRes.status === SCRIPT_STATUS_DISABLE) {
const scripts = matchScriptUuid.map((uuid) => {
const scriptRes = Object.assign({}, this.scriptMatchCache?.get(uuid));
// 判断脚本是否开启
if (scriptRes.status === SCRIPT_STATUS_DISABLE) {
return undefined;
}
// 如果是iframe,判断是否允许在iframe里运行
if (chromeSender.frameId) {
if (scriptRes.metadata.noframes) {
return undefined;
}
// 如果是iframe,判断是否允许在iframe里运行
if (chromeSender.frameId !== undefined) {
if (scriptRes.metadata.noframes) {
return undefined;
}
}
// 获取value
return scriptRes;
})
);
}
// 获取value
return scriptRes;
});
const enableScript = scripts.filter((item) => item);
const enableScript = scripts.filter((item) => item) as ScriptMatchInfo[];
// 加载value
await Promise.all(
enableScript.map(async (script) => {
await Promise.all([
// 加载value
...enableScript.map(async (script) => {
const value = await this.value.getScriptValue(script!);
script!.value = value;
})
);
script.value = value;
}),
// 加载resource
...enableScript.map(async (script) => {
const resource = await this.resource.getScriptResources(script);
script.resource = resource;
}),
]);
this.mq.emit("pageLoad", {
tabId: chromeSender.tab?.id,
@ -249,40 +318,57 @@ export class RuntimeService {
}
// 注册inject.js
registerInjectScript() {
chrome.userScripts.getScripts({ ids: ["scriptcat-inject"] }).then((res) => {
if (res.length == 0) {
chrome.userScripts.configureWorld({
csp: "script-src 'self' 'unsafe-inline' 'unsafe-eval' *",
messaging: true,
async registerInjectScript() {
// 如果没设置过, 则更新messageFlag
let messageFlag = await this.getMessageFlag();
if (!messageFlag) {
messageFlag = await this.messageFlag();
const injectJs = await fetch("inject.js").then((res) => res.text());
// 替换ScriptFlag
const code = `(function (MessageFlag) {\n${injectJs}\n})('${messageFlag}')`;
chrome.userScripts.configureWorld({
csp: "script-src 'self' 'unsafe-inline' 'unsafe-eval' *",
messaging: true,
});
const scripts: chrome.userScripts.RegisteredUserScript[] = [
{
id: "scriptcat-inject",
js: [{ code }],
matches: ["<all_urls>"],
allFrames: true,
world: "MAIN",
runAt: "document_start",
},
// 注册content
{
id: "scriptcat-content",
js: [{ file: "src/content.js" }],
matches: ["<all_urls>"],
allFrames: true,
runAt: "document_start",
world: "USER_SCRIPT",
},
];
try {
// 如果使用getScripts来判断, 会出现找不到的问题
// 另外如果使用
await chrome.userScripts.register(scripts);
} catch (e: any) {
LoggerCore.logger().error("register inject.js error", {
error: e,
});
fetch("inject.js")
.then((res) => res.text())
.then(async (injectJs) => {
// 替换ScriptFlag
const code = `(function (MessageFlag) {\n${injectJs}\n})('${await this.messageFlag()}')`;
chrome.userScripts.register([
{
id: "scriptcat-inject",
js: [{ code }],
matches: ["<all_urls>"],
allFrames: true,
world: "MAIN",
runAt: "document_start",
},
// 注册content
{
id: "scriptcat-content",
js: [{ file: "src/content.js" }],
matches: ["<all_urls>"],
allFrames: true,
runAt: "document_start",
world: "USER_SCRIPT",
},
]);
if (e.message?.indexOf("Duplicate script ID") !== -1) {
// 如果是重复注册, 则更新
chrome.userScripts.update(scripts, () => {
if (chrome.runtime.lastError) {
LoggerCore.logger().error("update inject.js error", {
error: chrome.runtime.lastError,
});
}
});
}
}
});
}
}
loadingScript: Promise<void> | null | undefined;
@ -325,8 +411,10 @@ export class RuntimeService {
this.scriptMatchCache.forEach((val, key) => {
scriptMatch[key] = val;
// 优化性能,将不需要的信息去掉
// 而且可能会超过缓存的存储限制
scriptMatch[key].code = "";
scriptMatch[key].value = {};
scriptMatch[key].resource = {};
});
return await Cache.getInstance().set("scriptMatch", scriptMatch);
}
@ -360,8 +448,11 @@ export class RuntimeService {
if (!this.scriptMatchCache) {
await this.loadScriptMatchInfo();
}
this.scriptMatchCache!.get(uuid)!.status = status;
this.saveScriptMatchInfo();
const script = await this.scriptMatchCache!.get(uuid);
if (script) {
script.status = status;
this.saveScriptMatchInfo();
}
}
async deleteScriptMatch(uuid: string) {
@ -377,15 +468,15 @@ export class RuntimeService {
// 加载页面脚本, 会把脚本信息放入缓存中
// 如果脚本开启, 则注册脚本
async loadPageScript(script: Script) {
const matches = script.metadata["match"];
const scriptRes = await this.script.buildScriptRunResource(script);
const matches = scriptRes.metadata["match"];
if (!matches) {
return;
}
const scriptRes = await this.script.buildScriptRunResource(script);
scriptRes.code = compileInjectScript(scriptRes);
matches.push(...(script.metadata["include"] || []));
matches.push(...(scriptRes.metadata["include"] || []));
const patternMatches = dealPatternMatches(matches);
const scriptMatchInfo: ScriptMatchInfo = Object.assign(
{ matches: patternMatches.result, excludeMatches: [], customizeExcludeMatches: [] },
@ -427,25 +518,36 @@ export class RuntimeService {
this.addScriptMatch(scriptMatchInfo);
// 如果脚本开启, 则注册脚本
if (script.status === SCRIPT_STATUS_ENABLE) {
if (!script.metadata["noframes"]) {
if (this.isEnableDeveloperMode && this.isEnableUserscribe && script.status === SCRIPT_STATUS_ENABLE) {
if (scriptRes.metadata["noframes"]) {
registerScript.allFrames = false;
} else {
registerScript.allFrames = true;
}
if (script.metadata["run-at"]) {
registerScript.runAt = getRunAt(script.metadata["run-at"]);
if (scriptRes.metadata["run-at"]) {
registerScript.runAt = getRunAt(scriptRes.metadata["run-at"]);
}
if (await Cache.getInstance().get("registryScript:" + script.uuid)) {
await chrome.userScripts.update([registerScript]);
const res = await chrome.userScripts.getScripts({ ids: [script.uuid] });
const logger = LoggerCore.logger({
name: script.name,
registerMatch: {
matches: registerScript.matches,
excludeMatches: registerScript.excludeMatches,
},
});
if (res.length > 0) {
await chrome.userScripts.update([registerScript], () => {
if (chrome.runtime.lastError) {
logger.error("update registerScript error", {
error: chrome.runtime.lastError,
});
}
});
} else {
await chrome.userScripts.register([registerScript], () => {
if (chrome.runtime.lastError) {
LoggerCore.logger().error("registerScript error", {
logger.error("registerScript error", {
error: chrome.runtime.lastError,
name: script.name,
registerMatch: {
matches: registerScript.matches,
excludeMatches: registerScript.excludeMatches,
},
});
}
});
@ -455,19 +557,17 @@ export class RuntimeService {
}
async unregistryPageScript(uuid: string) {
if (!(await Cache.getInstance().get("registryScript:" + uuid))) {
if (
!this.isEnableDeveloperMode ||
!this.isEnableUserscribe ||
!(await Cache.getInstance().get("registryScript:" + uuid))
) {
return;
}
chrome.userScripts.unregister(
{
ids: [uuid],
},
() => {
// 删除缓存
Cache.getInstance().del("registryScript:" + uuid);
// 修改脚本状态为disable
this.updateScriptStatus(uuid, SCRIPT_STATUS_DISABLE);
}
);
// 删除缓存
Cache.getInstance().del("registryScript:" + uuid);
// 修改脚本状态为disable
this.updateScriptStatus(uuid, SCRIPT_STATUS_DISABLE);
chrome.userScripts.unregister({ ids: [uuid] });
}
}

View File

@ -21,6 +21,7 @@ import { ResourceService } from "./resource";
import { ValueService } from "./value";
import { compileScriptCode } from "../content/utils";
import { SystemConfig } from "@App/pkg/config/config";
import i18n, { localePath } from "@App/locales/locales";
export class ScriptService {
logger: Logger;
@ -59,50 +60,55 @@ export class ScriptService {
// 读取脚本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,
this.openInstallPageByUrl(targetUrl, "user")
.catch((e) => {
logger.error("install script error", Logger.E(e));
// 不再重定向当前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],
},
},
condition: {
regexFilter: targetUrl,
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET],
},
},
],
},
() => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError);
],
},
() => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError);
}
}
}
);
});
);
})
.finally(() => {
// 回退到到安装页
chrome.scripting.executeScript({
target: { tabId: req.tabId },
func: function () {
history.back();
},
});
});
},
{
urls: [
"https://docs.scriptcat.org/docs/script_installation",
"https://docs.scriptcat.org/docs/script_installation/",
"https://docs.scriptcat.org/en/docs/script_installation/",
"https://www.tampermonkey.net/script_installation.php",
],
types: ["main_frame"],
}
);
// 获取i18n
// 重定向到脚本安装页
chrome.declarativeNetRequest.updateDynamicRules(
{
@ -114,7 +120,7 @@ export class ScriptService {
action: {
type: chrome.declarativeNetRequest.RuleActionType.REDIRECT,
redirect: {
regexSubstitution: "https://docs.scriptcat.org/docs/script_installation#url=\\0",
regexSubstitution: `https://docs.scriptcat.org${localePath}/docs/script_installation/#url=\\0`,
},
},
condition: {
@ -135,18 +141,31 @@ export class ScriptService {
);
}
public openInstallPageByUrl(url: string) {
public openInstallPageByUrl(url: string, source: InstallSource) {
const uuid = uuidv4();
return fetchScriptInfo(url, "user", false, uuidv4()).then((info) => {
return fetchScriptInfo(url, source, false, uuidv4()).then((info) => {
Cache.getInstance().set(CacheKey.scriptInstallInfo(uuid), info);
setTimeout(() => {
// 清理缓存
Cache.getInstance().del(CacheKey.scriptInstallInfo(uuid));
}, 60 * 1000);
}, 30 * 1000);
openInCurrentTab(`/src/install.html?uuid=${uuid}`);
});
}
// 直接通过url静默安装脚本
async installByUrl(url: string, source: InstallSource, subscribeUrl?: string) {
const info = await fetchScriptInfo(url, source, false, uuidv4());
const prepareScript = await prepareScriptByCode(info.code, url, info.uuid);
prepareScript.script.subscribeUrl = subscribeUrl;
this.installScript({
script: prepareScript.script,
code: info.code,
upsertBy: source,
});
return Promise.resolve(prepareScript.script);
}
// 获取安装信息
getInstallInfo(uuid: string) {
return Cache.getInstance().get(CacheKey.scriptInstallInfo(uuid));
@ -179,7 +198,7 @@ export class ScriptService {
});
logger.info("install success");
// 广播一下
this.mq.publish("installScript", { script, update });
this.mq.publish("installScript", { script, update, upsertBy });
return Promise.resolve({ update });
})
.catch((e: any) => {
@ -307,6 +326,54 @@ export class ScriptService {
});
}
async resetExclude({ uuid, exclude }: { uuid: string; exclude: string[] | undefined }) {
const script = await this.scriptDAO.get(uuid);
if (!script) {
throw new Error("script not found");
}
script.selfMetadata = script.selfMetadata || {};
if (exclude) {
script.selfMetadata.exclude = exclude;
} else {
delete script.selfMetadata.exclude;
}
return this.scriptDAO
.update(uuid, script)
.then(() => {
// 广播一下
this.mq.publish("installScript", { script, update: true });
return true;
})
.catch((e) => {
this.logger.error("reset exclude error", Logger.E(e));
throw e;
});
}
async resetMatch({ uuid, match }: { uuid: string; match: string[] | undefined }) {
const script = await this.scriptDAO.get(uuid);
if (!script) {
throw new Error("script not found");
}
script.selfMetadata = script.selfMetadata || {};
if (match) {
script.selfMetadata.match = match;
} else {
delete script.selfMetadata.match;
}
return this.scriptDAO
.update(uuid, script)
.then(() => {
// 广播一下
this.mq.publish("installScript", { script, update: true });
return true;
})
.catch((e) => {
this.logger.error("reset match error", Logger.E(e));
throw e;
});
}
async checkUpdate(uuid: string, source: "user" | "system") {
// 检查更新
const script = await this.scriptDAO.get(uuid);
@ -330,7 +397,7 @@ export class ScriptService {
}
const newVersion = metadata.version && metadata.version[0];
if (!newVersion) {
logger.error("parse version failed", { version: "" });
logger.error("parse version failed", { version: metadata.version });
return Promise.resolve(false);
}
let oldVersion = script.metadata.version && script.metadata.version[0];
@ -393,16 +460,16 @@ export class ScriptService {
});
}
checkScriptUpdate() {
async checkScriptUpdate() {
const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle();
if (!checkCycle) {
return;
}
this.scriptDAO.all().then(async (scripts) => {
const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle();
if (!checkCycle) {
return;
}
const check = await this.systemConfig.getUpdateDisableScript();
const checkDisableScript = await this.systemConfig.getUpdateDisableScript();
scripts.forEach(async (script) => {
// 是否检查禁用脚本
if (!check && script.status === SCRIPT_STATUS_DISABLE) {
if (!checkDisableScript && script.status === SCRIPT_STATUS_DISABLE) {
return;
}
// 检查是否符合
@ -418,6 +485,15 @@ export class ScriptService {
return this.checkUpdate(uuid, "user");
}
isInstalled({ name, namespace }: { name: string; namespace: string }) {
return this.scriptDAO.findByNameAndNamespace(name, namespace).then((script) => {
if (script) {
return { installed: true, version: script.metadata.version && script.metadata.version[0] };
}
return { installed: false };
});
}
init() {
this.listenerScriptInstall();
@ -430,7 +506,10 @@ export class ScriptService {
this.group.on("getCode", this.getCode.bind(this));
this.group.on("getScriptRunResource", this.buildScriptRunResource.bind(this));
this.group.on("excludeUrl", this.excludeUrl.bind(this));
this.group.on("resetMatch", this.resetMatch.bind(this));
this.group.on("resetExclude", this.resetExclude.bind(this));
this.group.on("requestCheckUpdate", this.requestCheckUpdate.bind(this));
this.group.on("isInstalled", this.isInstalled.bind(this));
// 定时检查更新, 每10分钟检查一次
chrome.alarms.create("checkScriptUpdate", {

View File

@ -0,0 +1,280 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { ScriptDAO } from "@App/app/repo/scripts";
import {
Subscribe,
SUBSCRIBE_STATUS_DISABLE,
SUBSCRIBE_STATUS_ENABLE,
SubscribeDAO,
SubscribeScript,
} from "@App/app/repo/subscribe";
import { SystemConfig } from "@App/pkg/config/config";
import { MessageQueue } from "@Packages/message/message_queue";
import { Group } from "@Packages/message/server";
import { InstallSource } from ".";
import { publishSubscribeInstall, subscribeSubscribeInstall } from "../queue";
import { ScriptService } from "./script";
import { checkSilenceUpdate, InfoNotification, ltever } from "@App/pkg/utils/utils";
import { fetchScriptInfo, prepareSubscribeByCode, ScriptInfo } from "@App/pkg/utils/script";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
export class SubscribeService {
logger: Logger;
subscribeDAO = new SubscribeDAO();
scriptDAO = new ScriptDAO();
constructor(
private systemConfig: SystemConfig,
private group: Group,
private mq: MessageQueue,
private scriptService: ScriptService
) {
this.logger = LoggerCore.logger().with({ service: "subscribe" });
}
async install(param: { subscribe: Subscribe }) {
const logger = this.logger.with({
subscribeUrl: param.subscribe.url,
name: param.subscribe.name,
});
try {
await this.subscribeDAO.save(param.subscribe);
logger.info("upsert subscribe success");
publishSubscribeInstall(this.mq, {
subscribe: param.subscribe,
});
return Promise.resolve(param.subscribe.url);
} catch (e) {
logger.error("upsert subscribe error", Logger.E(e));
return Promise.reject(e);
}
}
async delete(param: { url: string }) {
const logger = this.logger.with({
subscribeUrl: param.url,
});
const subscribe = await this.subscribeDAO.get(param.url);
if (!subscribe) {
logger.warn("subscribe not found");
return Promise.resolve(false);
}
try {
// 删除相关脚本
const scripts = await this.scriptDAO.find((_, value) => {
return value.subscribeUrl === param.url;
});
scripts.forEach((script) => {
this.scriptService.deleteScript(script.uuid);
});
// 删除订阅
await this.subscribeDAO.delete(param.url);
logger.info("delete subscribe success");
return Promise.resolve(true);
} catch (e) {
logger.error("uninstall subscribe error", Logger.E(e));
return Promise.reject(e);
}
}
// 更新订阅的脚本
async upsertScript(subscribe: Subscribe) {
const logger = this.logger.with({
url: subscribe.url,
name: subscribe.name,
});
// 对比脚本是否有变化
const addScript: string[] = [];
const removeScript: SubscribeScript[] = [];
const scriptUrl = subscribe.metadata.scripturl || [];
const scripts = Object.keys(subscribe.scripts);
scriptUrl.forEach((url) => {
// 不存在于已安装的脚本中, 则添加
if (!scripts.includes(url)) {
addScript.push(url);
}
});
scripts.forEach((url) => {
// 不存在于订阅的脚本中, 则删除
if (!scriptUrl.includes(url)) {
removeScript.push(subscribe.scripts[url]);
}
});
const notification: string[][] = [[], []];
const result: Promise<any>[] = [];
// 添加脚本
addScript.forEach((url) => {
result.push(
(async () => {
const script = await this.scriptService.installByUrl(url, "subscribe", subscribe.url);
subscribe.scripts[url] = {
url,
uuid: script.uuid,
};
notification[0].push(script.name);
return Promise.resolve(true);
})().catch((e) => {
logger.error("install script failed", Logger.E(e));
return Promise.resolve(false);
})
);
});
// 删除脚本
removeScript.forEach((item) => {
// 通过uuid查询脚本id
result.push(
(async () => {
const script = await this.scriptDAO.findByUUID(item.uuid);
if (script) {
notification[1].push(script.name);
// 删除脚本
this.scriptService.deleteScript(script.uuid);
}
return Promise.resolve(true);
})().catch((e) => {
logger.error("delete script failed", Logger.E(e));
return Promise.resolve(false);
})
);
});
await Promise.allSettled(result);
await this.subscribeDAO.update(subscribe.url, subscribe);
InfoNotification("订阅更新", `安装了:${notification[0].join(",")}\n删除了:${notification[1].join("\n")}`);
logger.info("subscribe update", {
install: notification[0],
update: notification[1],
});
return Promise.resolve(true);
}
// 检查更新
async checkUpdate(url: string, source: InstallSource) {
const subscribe = await this.subscribeDAO.get(url);
if (!subscribe) {
return Promise.resolve(false);
}
const logger = this.logger.with({
url: subscribe.url,
name: subscribe.name,
});
await this.subscribeDAO.update(url, { checktime: new Date().getTime() });
try {
const info = await fetchScriptInfo(subscribe.url, source, false, subscribe.url);
const { metadata } = info;
if (!metadata) {
logger.error("parse metadata failed");
return Promise.resolve(false);
}
const newVersion = metadata.version && metadata.version[0];
if (!newVersion) {
logger.error("parse version failed", { version: metadata.version });
return Promise.resolve(false);
}
let oldVersion = subscribe.metadata.version && subscribe.metadata.version[0];
if (!oldVersion) {
oldVersion = "0.0.0";
}
// 对比版本大小
if (ltever(newVersion, oldVersion, logger)) {
return Promise.resolve(false);
}
// 进行更新
this.openUpdatePage(info);
return Promise.resolve(true);
} catch (e) {
logger.error("check update failed", Logger.E(e));
return Promise.resolve(false);
}
}
async openUpdatePage(info: ScriptInfo) {
const logger = this.logger.with({
url: info.url,
});
// 是否静默更新
const silenceUpdate = await this.systemConfig.getSilenceUpdateScript();
if (silenceUpdate) {
try {
const newSubscribe = await prepareSubscribeByCode(info.code, info.url);
if (checkSilenceUpdate(newSubscribe.oldSubscribe!.metadata, newSubscribe.subscribe.metadata)) {
logger.info("silence update subscribe");
this.install({
subscribe: newSubscribe.subscribe,
});
return;
}
} catch (e) {
logger.error("prepare script failed", Logger.E(e));
}
}
Cache.getInstance().set(CacheKey.scriptInstallInfo(info.uuid), info);
chrome.tabs.create({
url: `/src/install.html?uuid=${info.uuid}`,
});
}
async checkSubscribeUpdate() {
const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle();
if (!checkCycle) {
return;
}
this.logger.debug("start check update");
const checkDisable = await this.systemConfig.getUpdateDisableScript();
const list = await this.subscribeDAO.find((_, value) => {
return value.checktime + checkCycle * 1000 < Date.now();
});
list.forEach((subscribe) => {
if (!checkDisable && subscribe.status === SUBSCRIBE_STATUS_ENABLE) {
return;
}
this.checkUpdate(subscribe.url, "system");
});
}
requestCheckUpdate(url: string) {
return this.checkUpdate(url, "user");
}
enable(param: { url: string; enable: boolean }) {
const logger = this.logger.with({
url: param.url,
});
return this.subscribeDAO
.update(param.url, {
status: param.enable ? SUBSCRIBE_STATUS_ENABLE : SUBSCRIBE_STATUS_DISABLE,
})
.then(() => {
logger.info("enable subscribe success");
return Promise.resolve(true);
})
.catch((e) => {
logger.error("enable subscribe error", Logger.E(e));
return Promise.reject(e);
});
}
init() {
this.group.on("install", this.install.bind(this));
this.group.on("delete", this.delete.bind(this));
this.group.on("checkUpdate", this.requestCheckUpdate.bind(this));
this.group.on("enable", this.enable.bind(this));
subscribeSubscribeInstall(this.mq, (message) => {
this.upsertScript(message.subscribe);
});
// 定时检查更新, 每10分钟检查一次
chrome.alarms.create("checkSubscribeUpdate", {
delayInMinutes: 10,
periodInMinutes: 10,
});
}
}

View File

@ -0,0 +1,551 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { Resource } from "@App/app/repo/resource";
import { Script, SCRIPT_STATUS_ENABLE, ScriptCodeDAO, ScriptDAO } from "@App/app/repo/scripts";
import BackupExport from "@App/pkg/backup/export";
import { BackupData, ResourceBackup, ScriptBackupData, ScriptOptions, ValueStorage } from "@App/pkg/backup/struct";
import FileSystem, { File } from "@Packages/filesystem/filesystem";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
import { Group, MessageSend } from "@Packages/message/server";
import JSZip from "jszip";
import { ValueService } from "./value";
import { ResourceService } from "./resource";
import dayjs from "dayjs";
import { createObjectURL } from "../offscreen/client";
import FileSystemFactory, { FileSystemType } from "@Packages/filesystem/factory";
import { CloudSyncConfig, SystemConfig } from "@App/pkg/config/config";
import { MessageQueue } from "@Packages/message/message_queue";
import { subscribeScriptDelete, subscribeScriptInstall } from "../queue";
import { isWarpTokenError } from "@Packages/filesystem/error";
import { errorMsg, InfoNotification } from "@App/pkg/utils/utils";
import { t } from "i18next";
import ChromeStorage from "@App/pkg/config/chrome_storage";
import { ScriptService } from "./script";
import { prepareScriptByCode } from "@App/pkg/utils/script";
import { InstallSource } from ".";
export type SynchronizeTarget = "local";
type SyncFiles = {
script: File;
meta: File;
};
export type SyncMeta = {
uuid: string;
origin?: string; // 脚本来源
downloadUrl?: string;
checkUpdateUrl?: string;
isDeleted?: boolean;
};
export class SynchronizeService {
logger: Logger;
scriptDAO = new ScriptDAO();
scriptCodeDAO = new ScriptCodeDAO();
storage: ChromeStorage = new ChromeStorage("sync", true);
constructor(
private send: MessageSend,
private group: Group,
private script: ScriptService,
private value: ValueService,
private resource: ResourceService,
private mq: MessageQueue,
private systemConfig: SystemConfig
) {
this.logger = LoggerCore.logger().with({ service: "synchronize" });
}
// 生成备份文件到文件系统
async backup(fs: FileSystem, uuids?: string[]) {
// 生成导出数据
const data: BackupData = {
script: await this.getScriptBackupData(uuids),
subscribe: [],
};
await new BackupExport(fs).export(data);
}
// 获取脚本备份数据
async getScriptBackupData(uuids?: string[]) {
if (uuids) {
const rets: Promise<ScriptBackupData>[] = [];
uuids.forEach((uuid) => {
rets.push(
this.scriptDAO.get(uuid).then((script) => {
if (script) {
return this.generateScriptBackupData(script);
}
return Promise.reject(new Error(`Script ${uuid} not found`));
})
);
});
return Promise.all(rets);
}
// 获取所有脚本
const list = await this.scriptDAO.all();
return Promise.all(list.map(async (script): Promise<ScriptBackupData> => this.generateScriptBackupData(script)));
}
async generateScriptBackupData(script: Script): Promise<ScriptBackupData> {
const code = await this.scriptCodeDAO.get(script.uuid);
if (!code) {
throw new Error(`Script ${script.uuid} code not found`);
}
const ret = {
code: code.code,
options: {
options: this.scriptOption(script),
settings: {
enabled: script.status === SCRIPT_STATUS_ENABLE,
position: script.sort,
},
meta: {
name: script.name,
uuid: script.uuid,
sc_uuid: script.uuid,
modified: script.updatetime,
file_url: script.downloadUrl,
subscribe_url: script.subscribeUrl,
},
},
// storage,
requires: [],
requiresCss: [],
resources: [],
} as unknown as ScriptBackupData;
const storage: ValueStorage = {
data: {},
ts: new Date().getTime(),
};
const values = await this.value.getScriptValue(script);
Object.keys(values).forEach((key) => {
storage.data[key] = values[key];
});
const requires = await this.resource.getResourceByType(script, "require");
const requiresCss = await this.resource.getResourceByType(script, "require-css");
const resources = await this.resource.getResourceByType(script, "resource");
ret.requires = this.resourceToBackdata(requires);
ret.requiresCss = this.resourceToBackdata(requiresCss);
ret.resources = this.resourceToBackdata(resources);
ret.storage = storage;
return Promise.resolve(ret);
}
resourceToBackdata(resource: { [key: string]: Resource }) {
const ret: ResourceBackup[] = [];
Object.keys(resource).forEach((key) => {
ret.push({
meta: {
name: this.getUrlName(resource[key].url),
url: resource[key].url,
ts: resource[key].updatetime || resource[key].createtime,
mimetype: resource[key].contentType,
},
source: resource[key]!.content || undefined,
base64: resource[key]!.base64,
});
});
return ret;
}
getUrlName(url: string): string {
let index = url.indexOf("?");
if (index !== -1) {
url = url.substring(0, index);
}
index = url.lastIndexOf("/");
if (index !== -1) {
url = url.substring(index + 1);
}
return url;
}
// 为了兼容tm
scriptOption(script: Script): ScriptOptions {
return {
check_for_updates: false,
comment: null,
compat_foreach: false,
compat_metadata: false,
compat_prototypes: false,
compat_wrappedjsobject: false,
compatopts_for_requires: true,
noframes: null,
override: {
merge_connects: true,
merge_excludes: true,
merge_includes: true,
merge_matches: true,
orig_connects: script.metadata.connect || [],
orig_excludes: script.metadata.exclude || [],
orig_includes: script.metadata.include || [],
orig_matches: script.metadata.match || [],
orig_noframes: script.metadata.noframe ? true : null,
orig_run_at: (script.metadata.run_at && script.metadata.run_at[0]) || "document-idle",
use_blockers: [],
use_connects: [],
use_excludes: [],
use_includes: [],
use_matches: [],
},
run_at: null,
};
}
// 请求导出文件
async requestExport(uuids?: string[]) {
const zip = new JSZip();
const fs = new ZipFileSystem(zip);
await this.backup(fs, uuids);
// 生成文件,并下载
const files = await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9,
},
comment: "Created by Scriptcat",
});
const url = await createObjectURL(this.send, files);
chrome.downloads.download({
url,
saveAs: true,
filename: `scriptcat-backup-${dayjs().format("YYYY-MM-DDTHH-mm-ss")}.zip`,
});
return Promise.resolve();
}
// 备份到云端
async backupToCloud({ type, params }: { type: FileSystemType; params: any }) {
// 首先生成zip文件
const zip = new JSZip();
const fs = new ZipFileSystem(zip);
await this.backup(fs);
this.logger.info("backup to cloud");
// 然后创建云端文件系统
let cloudFs = await FileSystemFactory.create(type, params);
try {
await cloudFs.createDir("ScriptCat");
cloudFs = await cloudFs.openDir("ScriptCat");
// 云端文件系统写入文件
const file = await cloudFs.create(`scriptcat-backup-${dayjs().format("YYYY-MM-DDTHH-mm-ss")}.zip`);
await file.write(
await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9,
},
comment: "Created by Scriptcat",
})
);
} catch (e) {
this.logger.error("backup to cloud error", Logger.E(e));
return Promise.reject(e);
}
return Promise.resolve();
}
// 开始一次云同步
async buildFileSystem(config: CloudSyncConfig) {
let fs: FileSystem;
try {
fs = await FileSystemFactory.create(config.filesystem, config.params[config.filesystem]);
// 创建base目录
await FileSystemFactory.mkdirAll(fs, "ScriptCat/sync");
fs = await fs.openDir("ScriptCat/sync");
} catch (e: any) {
this.logger.error("create filesystem error", Logger.E(e), {
type: config.filesystem,
});
// 判断错误是不是网络类型的错误, 网络类型的错误不做任何处理
// 如果是token失效之类的错误,通知用户并关闭云同步
if (isWarpTokenError(e)) {
InfoNotification(
`${t("sync_system_connect_failed")}, ${t("sync_system_closed")}`,
`${t("sync_system_closed_description")}\n${errorMsg(e)}`
);
this.systemConfig.setCloudSync({
...config,
enable: false,
});
}
throw e;
}
return fs;
}
// 同步一次
async syncOnce(fs: FileSystem) {
this.logger.info("start sync once");
// 获取文件列表
const list = await fs.list();
// 根据文件名生成一个map
const uuidMap = new Map<
string,
{
script?: File;
meta?: File;
}
>();
// 储存文件摘要,用于检测文件是否有变化
const fileDigestMap =
((await this.storage.get("file_digest")) as {
[key: string]: string;
}) || {};
list.forEach((file) => {
if (file.name.endsWith(".user.js")) {
const uuid = file.name.substring(0, file.name.length - 8);
let files = uuidMap.get(uuid);
if (!files) {
files = {};
uuidMap.set(uuid, files);
}
files.script = file;
} else if (file.name.endsWith(".meta.json")) {
const uuid = file.name.substring(0, file.name.length - 10);
let files = uuidMap.get(uuid);
if (!files) {
files = {};
uuidMap.set(uuid, files);
}
files.meta = file;
}
});
// 获取脚本列表
const scriptList = await this.scriptDAO.all();
// 遍历脚本列表生成一个map
const scriptMap = new Map<string, Script>();
scriptList.forEach((script) => {
scriptMap.set(script.uuid, script);
});
// 对比脚本列表和文件列表,进行同步
const result: Promise<void>[] = [];
uuidMap.forEach((file, uuid) => {
const script = scriptMap.get(uuid);
if (script) {
// 脚本存在但是文件不存在,则读取.meta.json内容判断是否需要删除脚本
if (!file.script) {
result.push(
new Promise((resolve) => {
const handler = async () => {
// 读取meta文件
const meta = await fs.open(file.meta!);
const metaJson = (await meta.read("string")) as string;
const metaObj = JSON.parse(metaJson) as SyncMeta;
if (metaObj.isDeleted) {
if (script) {
this.script.deleteScript(script.uuid);
InfoNotification("脚本删除同步", `脚本${script.name}已被删除`);
}
scriptMap.delete(uuid);
} else {
// 否则认为是一个无效的.meta文件,进行删除
await fs.delete(file.meta!.path);
}
resolve();
};
handler();
})
);
return;
}
// 过滤掉无变动的文件
if (fileDigestMap[file.script!.name] === file.script!.digest) {
// 删除了之后,剩下的就是需要上传的脚本了
scriptMap.delete(uuid);
return;
}
const updatetime = script.updatetime || script.createtime;
// 对比脚本更新时间和文件更新时间
if (updatetime > file.script!.updatetime) {
// 如果脚本更新时间大于文件更新时间,则上传文件
result.push(this.pushScript(fs, script));
} else {
// 如果脚本更新时间小于文件更新时间,则更新脚本
result.push(this.pullScript(fs, file as SyncFiles, script));
}
scriptMap.delete(uuid);
return;
}
// 如果脚本不存在,且文件存在,则安装脚本
if (file.script) {
result.push(this.pullScript(fs, file as SyncFiles));
}
});
// 上传剩下的脚本
scriptMap.forEach((script) => {
result.push(this.pushScript(fs, script));
});
// 忽略错误
await Promise.allSettled(result);
// 重新获取文件列表,保存文件摘要
await this.updateFileDigest(fs);
this.logger.info("sync complete");
return Promise.resolve();
}
async updateFileDigest(fs: FileSystem) {
const newList = await fs.list();
const newFileDigestMap: { [key: string]: string } = {};
newList.forEach((file) => {
newFileDigestMap[file.name] = file.digest;
});
await this.storage.set("file_digest", newFileDigestMap);
return Promise.resolve();
}
// 删除云端脚本数据
async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) {
const filename = `${uuid}.user.js`;
const logger = this.logger.with({
uuid: uuid,
file: filename,
});
try {
await fs.delete(filename);
if (syncDelete) {
// 留下一个.meta.json删除标记
const meta = await fs.create(`${uuid}.meta.json`);
await meta.write(
JSON.stringify(<SyncMeta>{
uuid: uuid,
// origin: script.origin,
// downloadUrl: script.downloadUrl,
// checkUpdateUrl: script.checkUpdateUrl,
isDeleted: true,
})
);
} else {
// 直接删除所有相关文件
await fs.delete(filename);
await fs.delete(`${uuid}.meta.json`);
}
logger.info("delete success");
} catch (e) {
logger.error("delete file error", Logger.E(e));
}
return Promise.resolve();
}
// 上传脚本
async pushScript(fs: FileSystem, script: Script) {
const filename = `${script.uuid}.user.js`;
const logger = this.logger.with({
uuid: script.uuid,
name: script.name,
file: filename,
});
try {
const w = await fs.create(filename);
// 获取脚本代码
const code = await this.scriptCodeDAO.get(script.uuid);
await w.write(code!.code);
const meta = await fs.create(`${script.uuid}.meta.json`);
await meta.write(
JSON.stringify(<SyncMeta>{
uuid: script.uuid,
origin: script.origin,
downloadUrl: script.downloadUrl,
checkUpdateUrl: script.checkUpdateUrl,
})
);
logger.info("push script success");
} catch (e) {
logger.error("push script error", Logger.E(e));
throw e;
}
return Promise.resolve();
}
async pullScript(fs: FileSystem, file: SyncFiles, script?: Script) {
const logger = this.logger.with({
uuid: script?.uuid || "",
name: script?.name || "",
file: file.script.name,
});
try {
// 读取代码文件
const r = await fs.open(file.script);
const code = (await r.read("string")) as string;
// 读取meta文件
const meta = await fs.open(file.meta);
const metaJson = (await meta.read("string")) as string;
const metaObj = JSON.parse(metaJson) as SyncMeta;
const prepareScript = await prepareScriptByCode(
code,
script?.downloadUrl || metaObj.downloadUrl || "",
script?.uuid || metaObj.uuid
);
prepareScript.script.origin = prepareScript.script.origin || metaObj.origin;
this.script.installScript({
script: prepareScript.script,
code: code,
upsertBy: "sync",
});
logger.info("pull script success");
} catch (e) {
logger.error("pull script error", Logger.E(e));
}
return Promise.resolve();
}
cloudSyncConfigChange(value: CloudSyncConfig) {
if (value.enable) {
// 开启云同步同步
this.buildFileSystem(value).then(async (fs) => {
await this.syncOnce(fs);
// 开启定时器, 一小时一次
chrome.alarms.create("cloudSync", {
periodInMinutes: 60,
});
});
} else {
// 停止计时器
chrome.alarms.clear("cloudSync");
}
}
async scriptInstall(params: { script: Script; update: boolean; upsertBy: InstallSource }) {
if (params.upsertBy === "sync") {
return;
}
// 判断是否开启了同步
const config = await this.systemConfig.getCloudSync();
if (config.enable) {
this.buildFileSystem(config).then(async (fs) => {
await this.pushScript(fs, params.script);
this.updateFileDigest(fs);
});
}
}
async scriptDelete(script: { uuid: string }) {
// 判断是否开启了同步
const config = await this.systemConfig.getCloudSync();
if (config.enable) {
this.buildFileSystem(config).then(async (fs) => {
await this.deleteCloudScript(fs, script.uuid, config.syncDelete);
});
}
}
init() {
this.group.on("export", this.requestExport.bind(this));
this.group.on("backupToCloud", this.backupToCloud.bind(this));
// this.group.on("import", this.openImportWindow.bind(this));
// 监听脚本变化, 进行同步
subscribeScriptInstall(this.mq, this.scriptInstall.bind(this));
subscribeScriptDelete(this.mq, this.scriptDelete.bind(this));
}
}

View File

@ -54,7 +54,11 @@ export class ValueService {
});
} else {
oldValue = valueModel.data[key];
valueModel.data[key] = value;
if (value === undefined) {
delete valueModel.data[key];
} else {
valueModel.data[key] = value;
}
await this.valueDAO.save(storageName, valueModel);
}
return true;

View File

@ -27,10 +27,15 @@ i18n.use(initReactI18next).init({
},
});
export let localePath = "";
chrome.i18n.getAcceptLanguages((lngs) => {
systemConfig.getLanguage().then((lng) => {
systemConfig.getLanguage(lngs).then((lng) => {
i18n.changeLanguage(lng);
dayjs.locale(lng.toLocaleLowerCase());
if (lng !== "zh-CN") {
localePath = "en";
}
});
});

View File

@ -68,6 +68,7 @@
"confirm_delete_backup_file": "确认删除备份文件",
"confirm_update": "确认更新",
"delete_success": "删除成功",
"deleting": "删除中",
"backup_strategy": "备份策略",
"under_construction": "建设中",
"development_debugging": "开发调试",
@ -364,5 +365,11 @@
"menu_expand_num_before": "菜单项超过",
"menu_expand_num_after": "个时,自动隐藏",
"script_name_cannot_be_set_to_empty": "脚本name不可以设置为空",
"eslint_config_format_error": "eslint配置格式错误"
"eslint_config_format_error": "eslint配置格式错误",
"export_success": "导出成功",
"get_backup_dir_url_failed": "获取备份目录地址失败",
"get_backup_files_failed": "获取备份文件失败",
"develop_mode_guide": "检测到当前未开启开发者模式,您的脚本无法正常使用,<a href=\"https://docs.scriptcat.org/docs/use/open-dev/\" target=\"black\" style=\"color: var(--color-text-1)\">👉点我了解如何开启</a>",
"enable_script_failed": "脚本开启失败",
"disable_script_failed": "脚本关闭失败"
}

View File

@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "__MSG_scriptcat__",
"version": "0.17.0.1002",
"version": "0.17.0.1005",
"author": "CodFrm",
"description": "__MSG_scriptcat_description__",
"options_ui": {

View File

@ -1,21 +1,19 @@
import migrate from "./app/migrate";
import { MessageSend } from "@Packages/message/server";
import LoggerCore from "./app/logger/core";
import DBWriter from "./app/logger/db_writer";
import { LoggerDAO } from "./app/repo/logger";
import MessageWriter from "./app/logger/message_writer";
import { OffscreenManager } from "./app/service/offscreen";
// 初始化数据库
migrate();
import { ExtensionMessageSend } from "@Packages/message/extension_message";
function main() {
// 初始化日志组件
const extensionMessage: MessageSend = new ExtensionMessageSend();
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
writer: new MessageWriter(extensionMessage),
labels: { env: "offscreen" },
});
loggerCore.logger().debug("offscreen start");
// 初始化管理器
const manager = new OffscreenManager();
const manager = new OffscreenManager(extensionMessage);
manager.initManager();
}

View File

@ -129,7 +129,7 @@ const CloudScriptPlan: React.FC<{
const url = URL.createObjectURL(files);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 1000);
}, 30 * 1000);
chrome.downloads.download({
url,
saveAs: true,

View File

@ -17,7 +17,6 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod
{ id, className, code, diffCode, editable },
ref
) => {
const settings = useAppSelector((state) => state.setting);
const [monacoEditor, setEditor] = useState<editor.IStandaloneCodeEditor>();
const [enableEslint, setEnableEslint] = useState(false);
const [eslintConfig, setEslintConfig] = useState("");

View File

@ -1,22 +1,12 @@
import { Resource } from "@App/app/repo/resource";
import { Script } from "@App/app/repo/scripts";
import { ResourceClient } from "@App/app/service/service_worker/client";
import { message } from "@App/pages/store/global";
import { base64ToBlob } from "@App/pkg/utils/script";
import {
Button,
Drawer,
Input,
Message,
Popconfirm,
Space,
Table,
} from "@arco-design/web-react";
import { Button, Drawer, Input, Message, Popconfirm, Space, Table } from "@arco-design/web-react";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
import { ColumnProps } from "@arco-design/web-react/es/Table";
import {
IconDelete,
IconDownload,
IconSearch,
} from "@arco-design/web-react/icon";
import { IconDelete, IconDownload, IconSearch } from "@arco-design/web-react/icon";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@ -33,14 +23,14 @@ const ScriptResource: React.FC<{
}> = ({ script, visible, onCancel, onOk }) => {
const [data, setData] = useState<ResourceListItem[]>([]);
const inputRef = useRef<RefInputType>(null);
// const resourceCtrl = IoC.instance(ResourceController) as ResourceController;
const { t } = useTranslation();
const resourceClient = new ResourceClient(message);
useEffect(() => {
if (!script) {
return () => {};
}
resourceCtrl.getResource(script).then((res) => {
resourceClient.getScriptResources(script).then((res) => {
const arr: ResourceListItem[] = [];
Object.keys(res).forEach((key) => {
// @ts-ignore
@ -120,10 +110,21 @@ const ScriptResource: React.FC<{
title={t("confirm_delete_resource")}
onOk={() => {
Message.info({
content: t("delete_success"),
content: t("deleting"),
});
resourceCtrl.deleteResource(value.id);
setData(data.filter((_, i) => i !== index));
resourceClient
.deleteResource(value.url)
.then(() => {
Message.info({
content: t("delete_success"),
});
setData(data.filter((_, i) => i !== index));
})
.catch((e) => {
Message.error({
content: t("delete_failed") + ": " + e.message,
});
});
}}
>
<Button type="text" iconOnly icon={<IconDelete />} />
@ -154,7 +155,7 @@ const ScriptResource: React.FC<{
onOk={() => {
setData((prev) => {
prev.forEach((v) => {
resourceCtrl.deleteResource(v.id);
resourceClient.deleteResource(v.url);
});
Message.info({
content: t("clear_success"),

View File

@ -1,31 +1,24 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Script } from "@App/app/repo/scripts";
import {
Space,
Popconfirm,
Button,
Divider,
Typography,
Modal,
Input,
} from "@arco-design/web-react";
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import { Space, Popconfirm, Button, Divider, Typography, Modal, Input } from "@arco-design/web-react";
import Table, { ColumnProps } from "@arco-design/web-react/es/Table";
import { IconDelete } from "@arco-design/web-react/icon";
import { scriptClient } from "@App/pages/store/features/script";
type MatchItem = {
// id是为了避免match重复
id: number;
match: string;
self: boolean;
hasMatch: boolean;
isExclude: boolean;
byUser: boolean;
hasMatch: boolean; // 是否已经匹配
isExclude: boolean; // 是否是排除项
};
const Match: React.FC<{
script: Script;
}> = ({ script }) => {
// const scriptCtrl = IoC.instance(ScriptController) as ScriptController;
const scriptDAO = new ScriptDAO();
const [match, setMatch] = useState<MatchItem[]>([]);
const [exclude, setExclude] = useState<MatchItem[]>([]);
const [matchValue, setMatchValue] = useState<string>("");
@ -37,7 +30,7 @@ const Match: React.FC<{
useEffect(() => {
if (script) {
// 从数据库中获取是简单处理数据一致性的问题
scriptCtrl.scriptDAO.findById(script.id).then((res) => {
scriptDAO.get(script.uuid).then((res) => {
if (!res) {
return;
}
@ -48,28 +41,17 @@ const Match: React.FC<{
});
const v: MatchItem[] = [];
matchArr.forEach((value, index) => {
if (matchMap.has(value)) {
v.push({
id: index,
match: value,
self: false,
hasMatch: false,
isExclude: false,
});
} else {
v.push({
id: index,
match: value,
self: true,
hasMatch: false,
isExclude: false,
});
}
v.push({
id: index,
match: value,
byUser: !matchMap.has(value),
hasMatch: false,
isExclude: false,
});
});
setMatch(v);
const excludeArr =
res.selfMetadata?.exclude || res.metadata.exclude || [];
const excludeArr = res.selfMetadata?.exclude || res.metadata.exclude || [];
const excludeMap = new Map<string, boolean>();
res.metadata.exclude?.forEach((m) => {
excludeMap.set(m, true);
@ -77,23 +59,13 @@ const Match: React.FC<{
const e: MatchItem[] = [];
excludeArr.forEach((value, index) => {
const hasMatch = matchMap.has(value);
if (excludeMap.has(value)) {
e.push({
id: index,
match: value,
self: false,
hasMatch,
isExclude: true,
});
} else {
e.push({
id: index,
match: value,
self: true,
hasMatch,
isExclude: true,
});
}
e.push({
id: index,
match: value,
byUser: !excludeMap.has(value),
hasMatch,
isExclude: true,
});
});
setExclude(e);
});
@ -108,8 +80,8 @@ const Match: React.FC<{
},
{
title: t("user_setting"),
dataIndex: "self",
key: "self",
dataIndex: "byUser",
key: "byUser",
width: 100,
render(col) {
if (col) {
@ -125,23 +97,24 @@ const Match: React.FC<{
return (
<Space>
<Popconfirm
title={`${t("confirm_delete_exclude")}${
item.hasMatch ? ` ${t("after_deleting_match_item")}` : ""
}`}
title={`${t("confirm_delete_exclude")}${item.hasMatch ? ` ${t("after_deleting_match_item")}` : ""}`}
onOk={() => {
exclude.splice(exclude.indexOf(item), 1);
scriptCtrl
// 删除所有排除
scriptClient
.resetExclude(
script.id,
script.uuid,
exclude.map((m) => m.match)
)
.then(() => {
setExclude([...exclude]);
// 如果包含在里面再加回match
if (item.hasMatch) {
match.push(item);
scriptCtrl
// 重置匹配
scriptClient
.resetMatch(
script.id,
script.uuid,
match.map((m) => m.match)
)
.then(() => {
@ -159,24 +132,22 @@ const Match: React.FC<{
return (
<Space>
<Popconfirm
title={`${t("confirm_delete_match")}${
item.self ? "" : ` ${t("after_deleting_exclude_item")}`
}`}
title={`${t("confirm_delete_match")}${item.byUser ? "" : ` ${t("after_deleting_exclude_item")}`}`}
onOk={() => {
match.splice(match.indexOf(item), 1);
scriptCtrl
scriptClient
.resetMatch(
script.id,
script.uuid,
match.map((m) => m.match)
)
.then(() => {
setMatch([...match]);
// 添加到exclue
if (!item.self) {
if (!item.byUser) {
exclude.push(item);
scriptCtrl
scriptClient
.resetExclude(
script.id,
script.uuid,
exclude.map((m) => m.match)
)
.then(() => {
@ -205,13 +176,13 @@ const Match: React.FC<{
match.push({
id: Math.random(),
match: matchValue,
self: true,
byUser: true,
hasMatch: false,
isExclude: false,
});
scriptCtrl
scriptClient
.resetMatch(
script.id,
script.uuid,
match.map((m) => m.match)
)
.then(() => {
@ -237,13 +208,13 @@ const Match: React.FC<{
exclude.push({
id: Math.random(),
match: excludeValue,
self: true,
byUser: true,
hasMatch: false,
isExclude: true,
});
scriptCtrl
scriptClient
.resetExclude(
script.id,
script.uuid,
exclude.map((m) => m.match)
)
.then(() => {
@ -276,7 +247,7 @@ const Match: React.FC<{
<Popconfirm
title={t("confirm_reset")}
onOk={() => {
scriptCtrl.resetMatch(script.id, undefined).then(() => {
scriptClient.resetMatch(script.uuid, undefined).then(() => {
setMatch([]);
});
}}
@ -305,7 +276,7 @@ const Match: React.FC<{
<Popconfirm
title={t("confirm_reset")}
onOk={() => {
scriptCtrl.resetExclude(script.id, undefined).then(() => {
scriptClient.resetExclude(script.uuid, undefined).then(() => {
setExclude([]);
});
}}

View File

@ -2,26 +2,14 @@ import React, { useEffect, useState } from "react";
import { Permission } from "@App/app/repo/permission";
import { Script } from "@App/app/repo/scripts";
import { useTranslation } from "react-i18next";
import {
Space,
Popconfirm,
Message,
Button,
Checkbox,
Input,
Modal,
Select,
Typography,
} from "@arco-design/web-react";
import { Space, Popconfirm, Message, Button, Checkbox, Input, Modal, Select, Typography } from "@arco-design/web-react";
import Table, { ColumnProps } from "@arco-design/web-react/es/Table";
import { IconDelete } from "@arco-design/web-react/icon";
import { permissionClient } from "@App/pages/store/features/script";
const PermissionManager: React.FC<{
script: Script;
}> = ({ script }) => {
// const permissionCtrl = IoC.instance(
// PermissionController
// ) as PermissionController;
const [permission, setPermission] = useState<Permission[]>([]);
const [permissionVisible, setPermissionVisible] = useState<boolean>(false);
const [permissionValue, setPermissionValue] = useState<Permission>();
@ -59,14 +47,15 @@ const PermissionManager: React.FC<{
<Popconfirm
title={t("confirm_delete_permission")}
onOk={() => {
permissionCtrl
.deletePermission(script!.id, {
permission: item.permission,
permissionValue: item.permissionValue,
})
permissionClient
.deletePermission(script.uuid, item.permission, item.permissionValue)
.then(() => {
Message.success(t("delete_success")!);
setPermission(permission.filter((i) => i.id !== item.id));
setPermission(
permission.filter(
(i) => !(i.permission == item.permission && i.permissionValue == item.permissionValue)
)
);
})
.catch(() => {
Message.error(t("delete_failed")!);
@ -83,7 +72,7 @@ const PermissionManager: React.FC<{
useEffect(() => {
if (script) {
permissionCtrl.getPermissions(script.id).then((list) => {
permissionClient.getScriptPermissions(script.uuid).then((list) => {
setPermission(list);
});
}
@ -98,20 +87,17 @@ const PermissionManager: React.FC<{
onOk={() => {
if (permissionValue) {
permission.push({
id: 0,
uuid: script.id,
uuid: script.uuid,
permission: permissionValue.permission,
permissionValue: permissionValue.permissionValue,
allow: permissionValue.allow,
createtime: new Date().getTime(),
updatetime: 0,
});
permissionCtrl
.addPermission(script.id, permissionValue)
.then(() => {
setPermission([...permission]);
setPermissionVisible(false);
});
permissionClient.addPermission(permissionValue).then(() => {
setPermission([...permission]);
setPermissionVisible(false);
});
}
}}
>
@ -119,27 +105,22 @@ const PermissionManager: React.FC<{
<Select
value={permissionValue?.permission}
onChange={(e) => {
permissionValue &&
setPermissionValue({ ...permissionValue, permission: e });
permissionValue && setPermissionValue({ ...permissionValue, permission: e });
}}
>
<Select.Option value="cors">{t("permission_cors")}</Select.Option>
<Select.Option value="cookie">
{t("permission_cookie")}
</Select.Option>
<Select.Option value="cookie">{t("permission_cookie")}</Select.Option>
</Select>
<Input
value={permissionValue?.permissionValue}
onChange={(e) => {
permissionValue &&
setPermissionValue({ ...permissionValue, permissionValue: e });
permissionValue && setPermissionValue({ ...permissionValue, permissionValue: e });
}}
/>
<Checkbox
checked={permissionValue?.allow}
onChange={(e) => {
permissionValue &&
setPermissionValue({ ...permissionValue, allow: e });
permissionValue && setPermissionValue({ ...permissionValue, allow: e });
}}
>
{t("allow")}
@ -147,17 +128,14 @@ const PermissionManager: React.FC<{
</Space>
</Modal>
<div className="flex flex-row justify-between pb-2">
<Typography.Title heading={6}>
{t("permission_management")}
</Typography.Title>
<Typography.Title heading={6}>{t("permission_management")}</Typography.Title>
<Space>
<Button
type="primary"
size="small"
onClick={() => {
setPermissionValue({
id: 0,
uuid: script.id,
uuid: script.uuid,
permission: "cors",
permissionValue: "",
allow: true,
@ -172,7 +150,7 @@ const PermissionManager: React.FC<{
<Popconfirm
title={t("confirm_reset")}
onOk={() => {
permissionCtrl.resetPermission(script.id).then(() => {
permissionClient.resetPermission(script.uuid).then(() => {
setPermission([]);
});
}}
@ -183,12 +161,7 @@ const PermissionManager: React.FC<{
</Popconfirm>
</Space>
</div>
<Table
columns={columns}
data={permission}
rowKey="id"
pagination={false}
/>
<Table columns={columns} data={permission} rowKey="id" pagination={false} />
</>
);
};

View File

@ -1,13 +1,6 @@
import { Script } from "@App/app/repo/scripts";
import { Script, ScriptDAO } from "@App/app/repo/scripts";
import { formatUnixTime } from "@App/pkg/utils/utils";
import {
Descriptions,
Divider,
Drawer,
Empty,
Input,
Message,
} from "@arco-design/web-react";
import { Descriptions, Divider, Drawer, Empty, Input, Message } from "@arco-design/web-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Match from "./Match";
@ -19,14 +12,14 @@ const ScriptSetting: React.FC<{
onOk: () => void;
onCancel: () => void;
}> = ({ script, visible, onCancel, onOk }) => {
// const scriptCtrl = IoC.instance(ScriptController) as ScriptController;
const scriptDAO = new ScriptDAO();
const [checkUpdateUrl, setCheckUpdateUrl] = useState<string>("");
const { t } = useTranslation();
useEffect(() => {
if (script) {
scriptCtrl.scriptDAO.findById(script.id).then((v) => {
scriptDAO.get(script.uuid).then((v) => {
setCheckUpdateUrl(v?.downloadUrl || "");
});
}
@ -56,9 +49,7 @@ const ScriptSetting: React.FC<{
data={[
{
label: t("last_updated"),
value: formatUnixTime(
(script?.updatetime || script?.createtime || 0) / 1000
),
value: formatUnixTime((script?.updatetime || script?.createtime || 0) / 1000),
},
{
label: "UUID",
@ -83,8 +74,8 @@ const ScriptSetting: React.FC<{
setCheckUpdateUrl(e);
}}
onBlur={() => {
scriptCtrl
.updateCheckUpdateUrl(script!.id, checkUpdateUrl)
scriptDAO
.update(script.uuid, { downloadUrl: checkUpdateUrl, checkUpdateUrl: checkUpdateUrl })
.then(() => {
Message.success(t("update_success")!);
});

View File

@ -1,5 +1,6 @@
import { Script } from "@App/app/repo/scripts";
import { Value } from "@App/app/repo/value";
import { valueClient } from "@App/pages/store/features/script";
import { valueType } from "@App/pkg/utils/utils";
import { Button, Drawer, Form, Input, Message, Modal, Popconfirm, Select, Space, Table } from "@arco-design/web-react";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
@ -10,16 +11,20 @@ import { useTranslation } from "react-i18next";
const FormItem = Form.Item;
interface ValueModel {
key: string;
value: any;
}
const ScriptStorage: React.FC<{
// eslint-disable-next-line react/require-default-props
script?: Script;
visible: boolean;
onOk: () => void;
onCancel: () => void;
}> = ({ script, visible, onCancel, onOk }) => {
const [data, setData] = useState<Value[]>([]);
const [data, setData] = useState<ValueModel[]>([]);
const inputRef = useRef<RefInputType>(null);
const [currentValue, setCurrentValue] = useState<Value>();
const [currentValue, setCurrentValue] = useState<ValueModel>();
const [visibleEdit, setVisibleEdit] = useState(false);
const [form] = Form.useForm();
const { t } = useTranslation();
@ -28,31 +33,13 @@ const ScriptStorage: React.FC<{
if (!script) {
return () => {};
}
// valueCtrl.getValues(script).then((values) => {
// setData(values);
// });
// Monitor value changes
// const channel = valueCtrl.watchValue(script);
// channel.setHandler((value: Value) => {
// setData((prev) => {
// const index = prev.findIndex((item) => item.key === value.key);
// if (index === -1) {
// if (value.value === undefined) {
// return prev;
// }
// return [value, ...prev];
// }
// if (value.value === undefined) {
// prev.splice(index, 1);
// return [...prev];
// }
// prev[index] = value;
// return [...prev];
// });
// });
return () => {
// channel.disChannel();
};
valueClient.getScriptValue(script).then((value) => {
setData(
Object.keys(value).map((key) => {
return { key: key, value: value[key] };
})
);
});
}, [script]);
const columns: ColumnProps[] = [
{
@ -61,7 +48,6 @@ const ScriptStorage: React.FC<{
key: "key",
filterIcon: <IconSearch />,
width: 140,
// eslint-disable-next-line react/no-unstable-nested-components
filterDropdown: ({ filterKeys, setFilterKeys, confirm }: any) => {
return (
<div className="arco-table-custom-filter">
@ -120,7 +106,7 @@ const ScriptStorage: React.FC<{
},
{
title: t("action"),
render(_col, value: Value, index) {
render(_col, value: { key: string; value: string }, index) {
return (
<Space>
<Button
@ -136,7 +122,7 @@ const ScriptStorage: React.FC<{
iconOnly
icon={<IconDelete />}
onClick={() => {
valueCtrl.setValue(script!.id, value.key, undefined);
valueClient.setScriptValue(script!.uuid, value.key, undefined);
Message.info({
content: t("delete_success"),
});
@ -179,7 +165,7 @@ const ScriptStorage: React.FC<{
default:
break;
}
valueCtrl.setValue(script!.id, value.key, value.value);
valueClient.setScriptValue(script!.uuid, value.key, value.value);
if (currentValue) {
Message.info({
content: t("update_success"),
@ -201,13 +187,8 @@ const ScriptStorage: React.FC<{
});
setData([
{
id: 0,
scriptId: script!.id,
storageName: (script?.metadata.storagename && script?.metadata.storagename[0]) || "",
key: value.key,
value: value.value,
createtime: Date.now(),
updatetime: 0,
},
...data,
]);
@ -254,7 +235,7 @@ const ScriptStorage: React.FC<{
onOk={() => {
setData((prev) => {
prev.forEach((v) => {
valueCtrl.setValue(script!.id, v.key, undefined);
valueClient.setScriptValue(script!.uuid, v.key, undefined);
});
Message.info({
content: t("clear_success"),

View File

@ -8,16 +8,13 @@ import "@App/index.css";
import { Provider } from "react-redux";
import { store } from "@App/pages/store/store.ts";
import LoggerCore from "@App/app/logger/core.ts";
import migrate from "@App/app/migrate.ts";
import { LoggerDAO } from "@App/app/repo/logger.ts";
import DBWriter from "@App/app/logger/db_writer.ts";
import MessageWriter from "@App/app/logger/message_writer.ts";
import { message } from "../store/global.ts";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
labels: { env: "install" },
writer: new MessageWriter(message),
labels: { env: "confirm" },
});
loggerCore.logger().debug("page start");

256
src/pages/import/App.tsx Normal file
View File

@ -0,0 +1,256 @@
import React, { useEffect, useState } from "react";
import { Button, Card, Checkbox, Divider, List, Message, Space, Switch, Typography } from "@arco-design/web-react";
import { useTranslation } from "react-i18next"; // 导入react-i18next的useTranslation钩子
import JSZip from "jszip";
import { ScriptBackupData, ScriptOptions, SubscribeBackupData } from "@App/pkg/backup/struct";
import { prepareScriptByCode } from "@App/pkg/utils/script";
import { Script, SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts";
import { Subscribe } from "@App/app/repo/subscribe";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
import { parseBackupZipFile } from "@App/pkg/backup/utils";
import { scriptClient, valueClient } from "../store/features/script";
type ScriptData = ScriptBackupData & {
script?: { script: Script; oldScript?: Script };
install: boolean;
error?: string;
};
type SubscribeData = SubscribeBackupData & {
subscribe?: Subscribe;
install: boolean;
};
function App() {
const [scripts, setScripts] = useState<ScriptData[]>([]);
const [subscribes, setSubscribe] = useState<SubscribeData[]>([]);
const [selectAll, setSelectAll] = useState([true, true]);
const [installNum, setInstallNum] = useState([0, 0]);
const [loading, setLoading] = useState(true);
const url = new URL(window.location.href);
const uuid = url.searchParams.get("uuid") || "";
const { t } = useTranslation(); // 使用useTranslation钩子获取翻译函数
useEffect(() => {
Cache.getInstance()
.get(CacheKey.importFile(uuid))
.then(async (resp: { filename: string; url: string }) => {
const filedata = await fetch(resp.url).then((resp) => resp.blob());
const zip = await JSZip.loadAsync(filedata);
const backData = await parseBackupZipFile(zip);
const backDataScript = backData.script as ScriptData[];
setScripts(backDataScript);
// 获取各个脚本现在已经存在的信息
const result = await Promise.all(
backDataScript.map(async (item) => {
try {
const prepareScript = await prepareScriptByCode(
item.code,
item.options?.meta.file_url || "",
item.options?.meta.sc_uuid || undefined,
true
);
item.script = prepareScript;
} catch (e: any) {
item.error = e.toString();
return Promise.resolve(item);
}
if (!item.options) {
item.options = {
options: {} as ScriptOptions,
meta: {
name: item.script?.script.name,
// 此uuid是对tm的兼容处理
uuid: item.script?.script.uuid,
sc_uuid: item.script?.script.uuid,
file_url: item.script?.script.downloadUrl || "",
modified: item.script?.script.createtime,
subscribe_url: item.script?.script.subscribeUrl,
},
settings: {
enabled:
item.enabled === false
? false
: !(item.script?.script.metadata.background || item.script?.script.metadata.crontab),
position: 0,
},
};
}
item.script.script.status =
item.enabled !== false && item.options.settings.enabled ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE;
item.install = true;
return Promise.resolve(item);
})
);
setScripts(result);
setSelectAll([true, true]);
setLoading(false);
})
.catch((e) => {
Message.error(`获取导入文件失败: ${e}`);
});
}, []);
return (
<div>
<Card bordered={false} title={t("data_import")}>
<Space direction="vertical" style={{ width: "100%" }}>
<Space>
<Button
type="primary"
loading={loading}
onClick={async () => {
setInstallNum((prev) => {
return [0, prev[1]];
});
setLoading(true);
const result = scripts.map(async (item) => {
const ok = true;
if (item.install && !item.error) {
await scriptClient.install(item.script?.script!, item.code);
// 导入数据
const { data } = item.storage;
Object.keys(data).forEach((key) => {
valueClient.setScriptValue(item.script?.script.uuid!, key, data[key]);
});
}
setInstallNum((prev) => {
return [prev[0] + 1, prev[1]];
});
return Promise.resolve(ok);
});
await Promise.all(result);
setLoading(false);
Message.success(t("import_success")!);
}}
>
{t("import")}
</Button>
<Button type="primary" status="danger" loading={loading} onClick={() => window.close()}>
{t("close")}
</Button>
</Space>
<Typography.Text>
{t("select_scripts_to_import")}:{" "}
<Checkbox
checked={selectAll[0]}
onChange={() => {
setScripts((prev) => {
setSelectAll([!selectAll[0], selectAll[1]]);
return prev.map((item) => {
item.install = !selectAll[0];
return item;
});
});
}}
>
{t("select_all")}
</Checkbox>
<Divider type="vertical" />
{t("script_import_progress")}: {installNum[0]}/{scripts.length}
</Typography.Text>
<Typography.Text>
{t("select_subscribes_to_import")}:{" "}
<Checkbox
checked={selectAll[1]}
onChange={() => {
setSubscribe((prev) => {
setSelectAll([selectAll[0], !selectAll[1]]);
return prev.map((item) => {
item.install = !selectAll[1];
return item;
});
});
}}
>
{t("select_all")}
</Checkbox>
<Divider type="vertical" />
{t("subscribe_import_progress")}: {installNum[1]}/{subscribes.length}
</Typography.Text>
<List
className="import-list"
loading={loading}
bordered={false}
dataSource={scripts}
render={(item, index) => (
<div
className="flex flex-row justify-between p-2"
key={`e_${index}`}
style={{
background: item.error ? "rgb(var(--red-1))" : item.install ? "rgb(var(--arcoblue-1))" : "",
borderBottom: "1px solid rgb(var(--gray-3))",
cursor: "pointer",
}}
onClick={() => {
const install = item.install;
setScripts((prev) => {
prev[index].install = !install;
return [...prev];
});
}}
>
<Space
direction="vertical"
size={1}
style={{
overflow: "hidden",
}}
>
<Typography.Title
heading={6}
style={{
color: "rgb(var(--blue-5))",
}}
>
{item.script?.script?.name || item.error || t("unknown")}
</Typography.Title>
<span className="text-sm color-gray-5">
{t("author")}: {item.script?.script?.metadata.author && item.script?.script?.metadata.author[0]}
</span>
<span className="text-sm color-gray-5">
{t("description")}:{" "}
{item.script?.script?.metadata.description && item.script?.script?.metadata.description[0]}
</span>
<span className="text-sm color-gray-5">
{t("source")}: {item.options?.meta.file_url || t("local_creation")}
</span>
<span className="text-sm color-gray-5">
{t("operation")}:{" "}
{(item.install && (item.script?.oldScript ? t("update") : t("add_new"))) ||
(item.error
? `${t("error")}: ${item.options?.meta.name} - ${item.options?.meta.uuid}`
: t("no_operation"))}
</span>
</Space>
<div
className="flex flex-col justify-between"
style={{
minWidth: "80px",
textAlign: "center",
}}
>
<span className="text-sm color-gray-5">{t("enable_script")}</span>
<div className="text-center">
<Switch
size="small"
checked={item.script?.script?.status === SCRIPT_STATUS_ENABLE}
onChange={(checked) => {
setScripts((prev) => {
prev[index].script!.script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE;
return [...prev];
});
}}
/>
</div>
</div>
</div>
)}
/>
</Space>
</Card>
</div>
);
}
export default App;

View File

@ -0,0 +1,3 @@
.import-list .arco-typography {
margin: 0;
}

30
src/pages/import/main.tsx Normal file
View File

@ -0,0 +1,30 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
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/pages/store/store.ts";
import LoggerCore from "@App/app/logger/core.ts";
import MessageWriter from "@App/app/logger/message_writer.ts";
import { message } from "../store/global.ts";
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new MessageWriter(message),
labels: { env: "import" },
});
loggerCore.logger().debug("page start");
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<MainLayout className="!flex-col !p-[10px] box-border h-auto overflow-auto">
<App />
</MainLayout>
</Provider>
</React.StrictMode>
);

View File

@ -7,7 +7,7 @@ import { i18nDescription, i18nName } from "@App/locales/locales";
import { useTranslation } from "react-i18next";
import { prepareScriptByCode, prepareSubscribeByCode, ScriptInfo } from "@App/pkg/utils/script";
import { nextTime } from "@App/pkg/utils/utils";
import { scriptClient } from "../store/features/script";
import { scriptClient, subscribeClient } from "../store/features/script";
type Permission = { label: string; color?: string; value: string[] }[];
@ -253,18 +253,18 @@ function App() {
return;
}
if (scriptInfo?.userSubscribe) {
// subscribeCtrl
// .upsert(upsertScript as Subscribe)
// .then(() => {
// Message.success(t("subscribe_success")!);
// setBtnText(t("subscribe_success")!);
// setTimeout(() => {
// closeWindow();
// }, 200);
// })
// .catch((e) => {
// Message.error(`${t("subscribe_failed")}: ${e}`);
// });
subscribeClient
.install(upsertScript as Subscribe)
.then(() => {
Message.success(t("subscribe_success")!);
setBtnText(t("subscribe_success")!);
setTimeout(() => {
closeWindow();
}, 500);
})
.catch((e) => {
Message.error(`${t("subscribe_failed")}: ${e}`);
});
return;
}
scriptClient

View File

@ -8,15 +8,12 @@ import "@App/index.css";
import { Provider } from "react-redux";
import { store } from "@App/pages/store/store.ts";
import LoggerCore from "@App/app/logger/core.ts";
import migrate from "@App/app/migrate.ts";
import { LoggerDAO } from "@App/app/repo/logger.ts";
import DBWriter from "@App/app/logger/db_writer.ts";
import MessageWriter from "@App/app/logger/message_writer.ts";
import { message } from "../store/global.ts";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
writer: new MessageWriter(message),
labels: { env: "install" },
});

View File

@ -8,16 +8,17 @@ import "@arco-design/web-react/dist/css/arco.css";
import "@App/locales/locales";
import "@App/index.css";
import "./index.css";
import migrate from "@App/app/migrate.ts";
import LoggerCore from "@App/app/logger/core.ts";
import { LoggerDAO } from "@App/app/repo/logger.ts";
import DBWriter from "@App/app/logger/db_writer.ts";
import registerEditor from "@App/pkg/utils/monaco-editor.ts";
import storeSubscribe from "../store/subscribe.ts";
import migrate from "@App/app/migrate.ts";
// 初始化数据库
migrate();
registerEditor();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),

View File

@ -81,9 +81,11 @@ import {
requestStopScript,
requestRunScript,
scriptClient,
enableLoading,
updateEnableStatus,
} from "@App/pages/store/features/script";
import { message, systemConfig } from "@App/pages/store/global";
import { ValueClient } from "@App/app/service/service_worker/client";
import { SynchronizeClient, ValueClient } from "@App/app/service/service_worker/client";
type ListType = Script & { loading?: boolean };
@ -615,6 +617,7 @@ function ScriptList() {
const dealColumns: ColumnProps[] = [];
newColumns.forEach((item) => {
console.log(newColumns);
switch (item.width) {
case -1:
break;
@ -625,37 +628,39 @@ function ScriptList() {
});
const sortIndex = dealColumns.findIndex((item) => item.key === "sort");
let SortableItem;
if (sortIndex !== -1) {
SortableItem = (props: any) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props!.record.uuid });
const SortableItem = (props: any) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: props!.record.uuid });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
// 替换排序列,使其可以拖拽
props.children[sortIndex + 1] = (
<td
className="arco-table-td"
style={{
textAlign: "center",
}}
key="drag"
>
<div className="arco-table-cell">
<IconMenu
style={{
cursor: "move",
}}
{...listeners}
/>
</div>
</td>
);
return <tr ref={setNodeRef} style={style} {...attributes} {...props} />;
};
// 替换排序列,使其可以拖拽
props.children[sortIndex + 1] = (
<td
className="arco-table-td"
style={{
textAlign: "center",
}}
key="drag"
>
<div className="arco-table-cell">
<IconMenu
style={{
cursor: "move",
}}
{...listeners}
/>
</div>
</td>
);
return <tr ref={setNodeRef} style={style} {...attributes} {...props} />;
};
}
const components: ComponentsProps = {
table: React.forwardRef(SortableWrapper),
@ -703,23 +708,37 @@ function ScriptList() {
type="primary"
size="mini"
onClick={() => {
const uuids: string[] = [];
const enableAction = (enable: boolean) => {
const uuids = select.map((item) => item.uuid);
dispatch(enableLoading({ uuids: uuids, loading: true }));
Promise.allSettled(uuids.map((uuid) => scriptClient.enable(uuid, enable))).finally(() => {
dispatch(updateEnableStatus({ uuids: uuids, enable: enable }));
dispatch(enableLoading({ uuids: uuids, loading: false }));
});
};
switch (action) {
case "enable":
select.forEach((item) => {
dispatch(requestEnableScript({ uuid: item.uuid, enable: true }));
});
enableAction(true);
break;
case "disable":
select.forEach((item) => {
dispatch(requestEnableScript({ uuid: item.uuid, enable: false }));
});
enableAction(false);
break;
case "export":
const uuids: string[] = [];
select.forEach((item) => {
uuids.push(item.uuid);
});
synchronizeCtrl.backup(uuids);
Message.loading({
id: "export",
content: t("exporting"),
});
new SynchronizeClient(message).export(uuids).then(() => {
Message.success({
id: "export",
content: t("export_success"),
duration: 3000,
});
});
break;
case "delete":
if (confirm(t("list.confirm_delete")!)) {

View File

@ -170,14 +170,15 @@ function Setting() {
return;
}
}
const params = { ...systemConfig.backup.params };
const cloudSync = await systemConfig.getCloudSync();
const params = { ...cloudSync.params };
params[fileSystemType] = fileSystemParams;
systemConfig.cloudSync = {
enable: enableCloudSync,
syncDelete,
systemConfig.setCloudSync({
enable: enableCloudSync || false,
syncDelete: syncDelete || false,
filesystem: fileSystemType,
params,
};
});
Message.success(t("save_success")!);
}}
>

View File

@ -1,47 +1,27 @@
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 { 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 { 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 的引用
import { subscribeClient } from "@App/pages/store/features/script";
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);
});
dao.all().then((subscribes) => {
setList(subscribes);
});
}, []);
const columns: ColumnProps[] = [
@ -50,7 +30,13 @@ function SubscribeList() {
dataIndex: "id",
width: 70,
key: "#",
sorter: (a, b) => a.id - b.id,
sorter: (a: Subscribe, b) => a.createtime - b.createtime,
render(col) {
if (col < 0) {
return "-";
}
return col + 1;
},
},
{
title: t("enable"),
@ -79,22 +65,18 @@ function SubscribeList() {
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;
subscribeClient
.enable(item.url, checked)
.then(() => {
list[index].status = checked ? SUBSCRIBE_STATUS_ENABLE : SUBSCRIBE_STATUS_DISABLE;
})
.catch((err) => {
Message.error(err);
})
.finally(() => {
list[index].loading = false;
setList([...list]);
});
} 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]);
});
}}
/>
);
@ -169,14 +151,7 @@ function SubscribeList() {
return <div />;
}
return (item.metadata.connect as string[]).map((val) => {
return (
<img
src={`https://${val}/favicon.ico`}
alt={val}
height={16}
width={16}
/>
);
return <img src={`https://${val}/favicon.ico`} alt={val} height={16} width={16} />;
});
},
},
@ -227,8 +202,8 @@ function SubscribeList() {
id: "checkupdate",
content: t("checking_for_updates"),
});
subscribeCtrl
.checkUpdate(subscribe.id)
subscribeClient
.checkUpdate(subscribe.url)
.then((res) => {
if (res) {
Message.warning({
@ -267,10 +242,15 @@ function SubscribeList() {
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}`);
});
subscribeClient
.delete(item.url)
.then(() => {
setList(list.filter((val) => val.url !== item.url));
Message.success(t("delete_success"));
})
.catch((e) => {
Message.error(`${t("delete_failed")}: ${e}`);
});
}}
>
<Button

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, 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 { formatUnixTime } from "@App/pkg/utils/utils";
@ -6,8 +6,13 @@ 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 { FileSystemType } from "@Packages/filesystem/factory";
import { systemConfig } from "@App/pages/store/global";
import FileSystemFactory, { FileSystemType } from "@Packages/filesystem/factory";
import { File, FileReader } from "@Packages/filesystem/filesystem";
import { message, systemConfig } from "@App/pages/store/global";
import { SynchronizeClient } from "@App/app/service/service_worker/client";
import { set } from "node_modules/yaml/dist/schema/yaml-1.1/set";
const synchronizeClient = new SynchronizeClient(message);
function Tools() {
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
@ -16,16 +21,26 @@ function Tools() {
const [fileSystemParams, setFilesystemParam] = useState<{
[key: string]: any;
}>({});
const [vscodeUrl, setVscodeUrl] = useState<string>("");
const [vscodeReconnect, setVscodeReconnect] = useState<boolean>(false);
const [backupFileList, setBackupFileList] = useState<File[]>([]);
const vscodeRef = useRef<RefInputType>(null);
const { t } = useTranslation();
useEffect(() => {
// 获取配置
systemConfig.getBackup().then((backup) => {
const loadConfig = async () => {
const [backup, vscodeUrl, vscodeReconnect] = await Promise.all([
systemConfig.getBackup(),
systemConfig.getVscodeUrl(),
systemConfig.getVscodeReconnect(),
]);
setFilesystemType(backup.filesystem);
setFilesystemParam(backup.params[backup.filesystem] || {});
});
setVscodeUrl(vscodeUrl);
setVscodeReconnect(vscodeReconnect);
};
loadConfig();
}, []);
return (
@ -49,7 +64,7 @@ function Tools() {
loading={loading.local}
onClick={async () => {
setLoading((prev) => ({ ...prev, local: true }));
await syncCtrl.backup();
await synchronizeClient.export();
setLoading((prev) => ({ ...prev, local: false }));
}}
>
@ -58,14 +73,24 @@ function Tools() {
<Button
type="primary"
onClick={() => {
syncCtrl
.openImportFile(fileRef.current!)
.then(() => {
const el = fileRef.current!;
el.onchange = async () => {
const { files } = el;
if (!files) {
return;
}
const file = files[0];
if (!file) {
return;
}
try {
await synchronizeClient.openImportWindow(file.name, file);
Message.success(t("select_import_script")!);
})
.then((e) => {
Message.error(`${t("import_error")}${e}`);
});
} catch (e) {
Message.error(`${t("import_error")}: ${e}`);
}
};
el.click();
}}
>
{t("import_file")}
@ -95,7 +120,7 @@ function Tools() {
});
setLoading((prev) => ({ ...prev, cloud: true }));
Message.info(t("preparing_backup")!);
syncCtrl
synchronizeClient
.backupToCloud(fileSystemType, fileSystemParams)
.then(() => {
Message.success(t("backup_success")!);
@ -113,9 +138,11 @@ function Tools() {
<Button
key="list"
type="primary"
loading={loading.cloud}
onClick={async () => {
let fs = await FileSystemFactory.create(fileSystemType, fileSystemParams);
setLoading((prev) => ({ ...prev, cloud: true }));
try {
let fs = await FileSystemFactory.create(fileSystemType, fileSystemParams);
fs = await fs.openDir("ScriptCat");
let list = await fs.list();
list.sort((a, b) => b.updatetime - a.updatetime);
@ -129,6 +156,7 @@ function Tools() {
} catch (e) {
Message.error(`${t("get_backup_files_failed")}: ${e}`);
}
setLoading((prev) => ({ ...prev, cloud: false }));
}}
>
{t("backup_list")}
@ -193,12 +221,8 @@ function Tools() {
Message.error(`${t("pull_failed")}: ${e}`);
return;
}
const url = URL.createObjectURL(data);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 100000);
syncCtrl
.openImportWindow(item.name, url)
synchronizeClient
.openImportWindow(item.name, data)
.then(() => {
Message.success(t("select_import_script")!);
})
@ -271,22 +295,24 @@ function Tools() {
<Title heading={6}>{t("vscode_url")}</Title>
<Input
ref={vscodeRef}
defaultValue={systemConfig.vscodeUrl}
value={vscodeUrl}
onChange={(value) => {
systemConfig.vscodeUrl = value;
setVscodeUrl(value);
}}
/>
<Checkbox
checked={vscodeReconnect}
onChange={(checked) => {
systemConfig.vscodeReconnect = checked;
setVscodeReconnect(checked);
}}
defaultChecked={systemConfig.vscodeReconnect}
>
{t("auto_connect_vscode_service")}
</Checkbox>
<Button
type="primary"
onClick={() => {
systemConfig.setVscodeUrl(vscodeUrl);
systemConfig.setVscodeReconnect(vscodeReconnect);
const ctrl = IoC.instance(SystemController) as SystemController;
ctrl
.connectVSCode()

View File

@ -1,4 +1,4 @@
import { Script, ScriptAndCode, ScriptCodeDAO, ScriptDAO } from "@App/app/repo/scripts";
import { Script, SCRIPT_TYPE_NORMAL, ScriptAndCode, ScriptCodeDAO, ScriptDAO } from "@App/app/repo/scripts";
import CodeEditor from "@App/pages/components/CodeEditor";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
@ -16,7 +16,7 @@ import { prepareScriptByCode } from "@App/pkg/utils/script";
import ScriptStorage from "@App/pages/components/ScriptStorage";
import ScriptResource from "@App/pages/components/ScriptResource";
import ScriptSetting from "@App/pages/components/ScriptSetting";
import { scriptClient } from "@App/pages/store/features/script";
import { runtimeClient, scriptClient } from "@App/pages/store/features/script";
import { i18nName } from "@App/locales/locales";
import { useTranslation } from "react-i18next";
@ -32,11 +32,12 @@ type HotKey = {
const Editor: React.FC<{
id: string;
script: ScriptAndCode;
script: Script;
code: string;
hotKeys: HotKey[];
callbackEditor: (e: editor.IStandaloneCodeEditor) => void;
onChange: (code: string) => void;
}> = ({ id, script, hotKeys, callbackEditor, onChange }) => {
}> = ({ id, script, code, hotKeys, callbackEditor, onChange }) => {
const [node, setNode] = useState<{ editor: editor.IStandaloneCodeEditor }>();
const ref = useCallback<(node: { editor: editor.IStandaloneCodeEditor }) => void>(
(inlineNode) => {
@ -77,7 +78,7 @@ const Editor: React.FC<{
};
}, [node?.editor]);
return <CodeEditor key={id} id={id} ref={ref} code={script.code} diffCode="" editable />;
return <CodeEditor key={id} id={id} ref={ref} code={code} diffCode="" editable />;
};
const WarpEditor = React.memo(Editor, (prev, next) => {
@ -154,7 +155,8 @@ function ScriptEditor() {
const [visible, setVisible] = useState<{ [key: string]: boolean }>({});
const [editors, setEditors] = useState<
{
script: ScriptAndCode;
script: Script;
code: string;
active: boolean;
hotKeys: HotKey[];
editor?: editor.IStandaloneCodeEditor;
@ -186,57 +188,58 @@ function ScriptEditor() {
const save = (script: Script, e: editor.IStandaloneCodeEditor): Promise<Script> => {
// 解析code生成新的script并更新
return new Promise(() => {
prepareScriptByCode(e.getValue(), script.origin || "", script.uuid)
.then((prepareScript) => {
const newScript = prepareScript.script;
if (!newScript.name) {
Message.warning(t("script_name_cannot_be_set_to_empty"));
return;
}
scriptClient.install(newScript, e.getValue()).then(
(update) => {
if (!update) {
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].script.code = prepareScript.scriptCode;
prev[i].isChanged = false;
prev[i].script.name = newScript.name;
break;
return prepareScriptByCode(e.getValue(), script.origin || "", script.uuid)
.then((prepareScript) => {
const newScript = prepareScript.script;
if (!newScript.name) {
Message.warning(t("script_name_cannot_be_set_to_empty"));
return Promise.reject(new Error("script name cannot be empty"));
}
return scriptClient
.install(newScript, e.getValue())
.then((update): Script => {
if (!update) {
Message.success("新建成功,请注意后台脚本不会默认开启");
// 保存的时候如何左侧没有脚本即新建
setScriptList((prev) => {
setSelectSciptButtonAndTab(newScript.uuid);
return [newScript, ...prev];
});
} else {
setScriptList((prev) => {
prev.map((script: Script) => {
if (script.uuid === newScript.uuid) {
script.name = newScript.name;
}
}
});
return [...prev];
});
},
(err: any) => {
Message.error(`保存失败: ${err}`);
Message.success("保存成功");
}
);
})
.catch((err) => {
Message.error(`错误的脚本代码: ${err}`);
});
});
setEditors((prev) => {
for (let i = 0; i < prev.length; i += 1) {
if (prev[i].script.uuid === newScript.uuid) {
prev[i].code = e.getValue();
prev[i].isChanged = false;
prev[i].script.name = newScript.name;
break;
}
}
return [...prev];
});
return newScript;
})
.catch((err: any) => {
Message.error(`保存失败: ${err}`);
return Promise.reject(err);
});
})
.catch((err) => {
Message.error(`错误的脚本代码: ${err}`);
return Promise.reject(err);
});
};
const saveAs = (script: Script, e: editor.IStandaloneCodeEditor) => {
return new Promise<void>((resolve) => {
chrome.downloads.download(
@ -287,30 +290,35 @@ function ScriptEditor() {
title: t("run"),
items: [
{
id: "debug",
title: t("debug"),
id: "run",
title: t("run"),
hotKey: KeyMod.CtrlCmd | KeyCode.F5,
hotKeyString: "Ctrl+F5",
tooltip: "只有后台脚本/定时脚本才能调试, 且调试模式下不对进行权限校验(例如@connect)",
tooltip: "只有后台脚本/定时脚本才能运行",
action: async (script, e) => {
// 保存更新代码之后再调试
const newScript = await save(script, e);
// 判断脚本类型
if (newScript.type === SCRIPT_TYPE_NORMAL) {
Message.error("只有后台脚本/定时脚本才能运行");
return;
}
Message.loading({
id: "debug_script",
content: "正在准备脚本资源...",
duration: 3000,
});
runtimeCtrl
.debugScript(newScript)
runtimeClient
.runScript(newScript.uuid)
.then(() => {
Message.success({
id: "debug_script",
content: "构建成功, 可以打开开发者工具在控制台中查看输出",
content: "构建成功, 可以在扩展页打开开发者工具在控制台中查看输出",
duration: 3000,
});
})
.catch((err) => {
LoggerCore.getLogger(Logger.E(err)).debug("debug script error");
LoggerCore.logger(Logger.E(err)).debug("run script error");
Message.error({
id: "debug_script",
content: `构建失败: ${err}`,
@ -398,7 +406,8 @@ function ScriptEditor() {
});
}
prev.push({
script: Object.assign(scripts[i], code),
script: scripts[i],
code: code?.code || "",
active: true,
hotKeys,
isChanged: false,
@ -725,7 +734,8 @@ function ScriptEditor() {
return;
}
editors.push({
script: Object.assign(script, code),
script,
code: code.code,
active: true,
hotKeys,
isChanged: false,
@ -874,6 +884,7 @@ function ScriptEditor() {
key={`e_${item.script.uuid}`}
id={`e_${item.script.uuid}`}
script={item.script}
code={item.code}
hotKeys={item.hotKeys}
callbackEditor={(e) => {
setEditors((prev) => {
@ -886,7 +897,7 @@ function ScriptEditor() {
});
}}
onChange={(code) => {
const isChanged = !(item.script.code === code);
const isChanged = !(item.code === code);
if (isChanged !== item.isChanged) {
setEditors((prev) => {
prev.forEach((v) => {

View File

@ -11,12 +11,14 @@ import {
IconSearch,
} from "@arco-design/web-react/icon";
import React, { useEffect, useState } from "react";
import { RiMessage2Line } from "react-icons/ri";
import { RiMessage2Line, RiZzzFill } from "react-icons/ri";
import semver from "semver";
import { useTranslation } from "react-i18next";
import ScriptMenuList from "../components/ScriptMenuList";
import { popupClient } from "../store/features/script";
import { ScriptMenu } from "@App/app/service/service_worker/popup";
import { systemConfig } from "../store/global";
import { isUserScriptsAvailable } from "@App/pkg/utils/utils";
const CollapseItem = Collapse.Item;
@ -30,11 +32,13 @@ function App() {
const [scriptList, setScriptList] = useState<ScriptMenu[]>([]);
const [backScriptList, setBackScriptList] = useState<ScriptMenu[]>([]);
const [showAlert, setShowAlert] = useState(false);
const [notice, setNotice] = useState("");
const [isRead, setIsRead] = useState(true);
const [version, setVersion] = useState(ExtVersion);
const [checkUpdate, setCheckUpdate] = useState<Parameters<typeof systemConfig.setCheckUpdate>[0]>({
version: ExtVersion,
notice: "",
isRead: false,
});
const [currentUrl, setCurrentUrl] = useState("");
const [isEnableScript, setIsEnableScript] = useState(localStorage.enable_script !== "false");
const [isEnableScript, setIsEnableScript] = useState(true);
const { t } = useTranslation();
let url: URL | undefined;
@ -45,22 +49,21 @@ function App() {
}
useEffect(() => {
// systemManage.getNotice().then((res) => {
// if (res) {
// setNotice(res.notice);
// setIsRead(res.isRead);
// }
// });
// systemManage.getVersion().then((res) => {
// res && setVersion(res);
// });
const loadConfig = async () => {
const [isEnableScript, checkUpdate] = await Promise.all([
systemConfig.getEnableScript(),
systemConfig.getCheckUpdate(),
]);
setIsEnableScript(isEnableScript);
setCheckUpdate(checkUpdate);
};
loadConfig();
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (!tabs.length) {
return;
}
setCurrentUrl(tabs[0].url || "");
popupClient.getPopupData({ url: tabs[0].url!, tabId: tabs[0].id! }).then((resp) => {
console.log(resp);
// 按照开启状态和更新时间排序
const list = resp.scriptList;
list.sort((a, b) => {
@ -82,141 +85,147 @@ function App() {
});
}, []);
return (
<Card
size="small"
title={
<div className="flex justify-between">
<span className="text-xl">ScriptCat</span>
<div className="flex flex-row items-center">
<Switch
size="small"
checked={isEnableScript}
onChange={(val) => {
setIsEnableScript(val);
if (val) {
localStorage.enable_script = "true";
} else {
localStorage.enable_script = "false";
}
}}
/>
<Button
type="text"
icon={<IconHome />}
iconOnly
onClick={() => {
// 用a链接的方式,vivaldi竟然会直接崩溃
window.open("/src/options.html", "_blank");
}}
/>
<Badge count={isRead ? 0 : 1} dot offset={[-8, 6]}>
<Button
type="text"
icon={<IconNotification />}
iconOnly
onClick={() => {
setShowAlert(!showAlert);
setIsRead(true);
systemManage.setRead(true);
<>
{!isUserScriptsAvailable() && (
<Alert type="warning" content={<div dangerouslySetInnerHTML={{ __html: t("develop_mode_guide") }} />} />
)}
<Card
size="small"
title={
<div className="flex justify-between">
<span className="text-xl">ScriptCat</span>
<div className="flex flex-row items-center">
<Switch
size="small"
checked={isEnableScript}
onChange={(val) => {
setIsEnableScript(val);
if (val) {
systemConfig.setEnableScript(true);
} else {
systemConfig.setEnableScript(false);
}
}}
/>
</Badge>
<Dropdown
droplist={
<Menu
style={{
maxHeight: "none",
<Button
type="text"
icon={<IconHome />}
iconOnly
onClick={() => {
// 用a链接的方式,vivaldi竟然会直接崩溃
window.open("/src/options.html", "_blank");
}}
/>
<Badge count={checkUpdate.isRead ? 0 : 1} dot offset={[-8, 6]}>
<Button
type="text"
icon={<IconNotification />}
iconOnly
onClick={() => {
setShowAlert(!showAlert);
checkUpdate.isRead = true;
setCheckUpdate(checkUpdate);
systemConfig.setCheckUpdate(checkUpdate);
}}
onClickMenuItem={async (key) => {
switch (key) {
case "newScript":
await chrome.storage.local.set({
activeTabUrl: {
url: currentUrl,
},
});
window.open("/src/options.html#/script/editor?target=initial", "_blank");
break;
default:
window.open(key, "_blank");
break;
}
}}
>
<Menu.Item key="newScript">
<IconPlus style={iconStyle} />
{t("create_script")}
</Menu.Item>
<Menu.Item key={`https://scriptcat.org/search?domain=${url && url.host}`}>
<IconSearch style={iconStyle} />
{t("get_script")}
</Menu.Item>
<Menu.Item key="https://github.com/scriptscat/scriptcat/issues">
<IconBug style={iconStyle} />
{t("report_issue")}
</Menu.Item>
<Menu.Item key="https://docs.scriptcat.org/">
<IconBook style={iconStyle} />
{t("project_docs")}
</Menu.Item>
<Menu.Item key="https://bbs.tampermonkey.net.cn/">
<RiMessage2Line style={iconStyle} />
{t("community")}
</Menu.Item>
<Menu.Item key="https://github.com/scriptscat/scriptcat">
<IconGithub style={iconStyle} />
GitHub
</Menu.Item>
</Menu>
}
trigger="click"
>
<Button type="text" icon={<IconMoreVertical />} iconOnly />
</Dropdown>
/>
</Badge>
<Dropdown
droplist={
<Menu
style={{
maxHeight: "none",
}}
onClickMenuItem={async (key) => {
switch (key) {
case "newScript":
await chrome.storage.local.set({
activeTabUrl: {
url: currentUrl,
},
});
window.open("/src/options.html#/script/editor?target=initial", "_blank");
break;
default:
window.open(key, "_blank");
break;
}
}}
>
<Menu.Item key="newScript">
<IconPlus style={iconStyle} />
{t("create_script")}
</Menu.Item>
<Menu.Item key={`https://scriptcat.org/search?domain=${url && url.host}`}>
<IconSearch style={iconStyle} />
{t("get_script")}
</Menu.Item>
<Menu.Item key="https://github.com/scriptscat/scriptcat/issues">
<IconBug style={iconStyle} />
{t("report_issue")}
</Menu.Item>
<Menu.Item key="https://docs.scriptcat.org/">
<IconBook style={iconStyle} />
{t("project_docs")}
</Menu.Item>
<Menu.Item key="https://bbs.tampermonkey.net.cn/">
<RiMessage2Line style={iconStyle} />
{t("community")}
</Menu.Item>
<Menu.Item key="https://github.com/scriptscat/scriptcat">
<IconGithub style={iconStyle} />
GitHub
</Menu.Item>
</Menu>
}
trigger="click"
>
<Button type="text" icon={<IconMoreVertical />} iconOnly />
</Dropdown>
</div>
</div>
</div>
}
bodyStyle={{ padding: 0 }}
>
<Alert
style={{ marginBottom: 20, display: showAlert ? "flex" : "none" }}
type="info"
content={<div dangerouslySetInnerHTML={{ __html: notice }} />}
/>
<Collapse bordered={false} defaultActiveKey={["script", "background"]} style={{ maxWidth: 640 }}>
<CollapseItem
header={t("current_page_scripts")}
name="script"
style={{ padding: "0" }}
contentStyle={{ padding: "0" }}
>
<ScriptMenuList script={scriptList} isBackscript={false} currentUrl={currentUrl} />
</CollapseItem>
<CollapseItem
header={t("enabled_background_scripts")}
name="background"
style={{ padding: "0" }}
contentStyle={{ padding: "0" }}
>
<ScriptMenuList script={backScriptList} isBackscript currentUrl={currentUrl} />
</CollapseItem>
</Collapse>
<div className="flex flex-row arco-card-header !h-6">
<span className="text-[12px] font-500">{`v${ExtVersion}`}</span>
{semver.lt(ExtVersion, version) && (
<span
onClick={() => {
window.open(`https://github.com/scriptscat/scriptcat/releases/tag/v${version}`);
}}
className="text-1 font-500"
style={{ cursor: "pointer" }}
}
bodyStyle={{ padding: 0 }}
>
<Alert
style={{ display: showAlert ? "flex" : "none" }}
type="info"
content={<div dangerouslySetInnerHTML={{ __html: checkUpdate.notice || "" }} />}
/>
<Collapse bordered={false} defaultActiveKey={["script", "background"]} style={{ maxWidth: 640 }}>
<CollapseItem
header={t("current_page_scripts")}
name="script"
style={{ padding: "0" }}
contentStyle={{ padding: "0" }}
>
{t("popup.new_version_available")}
</span>
)}
</div>
</Card>
<ScriptMenuList script={scriptList} isBackscript={false} currentUrl={currentUrl} />
</CollapseItem>
<CollapseItem
header={t("enabled_background_scripts")}
name="background"
style={{ padding: "0" }}
contentStyle={{ padding: "0" }}
>
<ScriptMenuList script={backScriptList} isBackscript currentUrl={currentUrl} />
</CollapseItem>
</Collapse>
<div className="flex flex-row arco-card-header !h-6">
<span className="text-[12px] font-500">{`v${ExtVersion}`}</span>
{semver.lt(ExtVersion, checkUpdate.version) && (
<span
onClick={() => {
window.open(`https://github.com/scriptscat/scriptcat/releases/tag/v${checkUpdate.version}`);
}}
className="text-1 font-500"
style={{ cursor: "pointer" }}
>
{t("popup.new_version_available")}
</span>
)}
</div>
</Card>
</>
);
}

View File

@ -2,21 +2,18 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import LoggerCore from "@App/app/logger/core.ts";
import migrate from "@App/app/migrate.ts";
import { LoggerDAO } from "@App/app/repo/logger.ts";
import DBWriter from "@App/app/logger/db_writer.ts";
import "@arco-design/web-react/dist/css/arco.css";
import "@App/locales/locales";
import "@App/index.css";
import "./index.css";
import { Provider } from "react-redux";
import { store } from "../store/store.ts";
import MessageWriter from "@App/app/logger/message_writer.ts";
import { message } from "../store/global.ts";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
writer: new MessageWriter(message),
labels: { env: "install" },
});

View File

@ -8,13 +8,22 @@ import {
ScriptDAO,
} from "@App/app/repo/scripts";
import { arrayMove } from "@dnd-kit/sortable";
import { PermissionClient, PopupClient, RuntimeClient, ScriptClient } from "@App/app/service/service_worker/client";
import {
PermissionClient,
PopupClient,
RuntimeClient,
ScriptClient,
SubscribeClient,
ValueClient,
} from "@App/app/service/service_worker/client";
import { message } from "../global";
export const scriptClient = new ScriptClient(message);
export const subscribeClient = new SubscribeClient(message);
export const runtimeClient = new RuntimeClient(message);
export const popupClient = new PopupClient(message);
export const permissionClient = new PermissionClient(message);
export const valueClient = new ValueClient(message);
export const fetchAndSortScriptList = createAsyncThunk("script/fetchScriptList", async () => {
// 排序
@ -93,6 +102,22 @@ export const scriptSlice = createAppSlice({
script.runStatus = action.payload.runStatus;
}
},
updateEnableStatus: (state, action: PayloadAction<{ uuids: string[]; enable: boolean }>) => {
state.scripts = state.scripts.map((s) => {
if (action.payload.uuids.includes(s.uuid)) {
s.status = action.payload.enable ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE;
}
return s;
});
},
enableLoading(state, action: PayloadAction<{ uuids: string[]; loading: boolean }>) {
state.scripts = state.scripts.map((s) => {
if (action.payload.uuids.includes(s.uuid)) {
s.enableLoading = action.payload.loading;
}
return s;
});
},
},
extraReducers: (builder) => {
builder
@ -135,6 +160,6 @@ export const scriptSlice = createAppSlice({
},
});
export const { sortScript, upsertScript, deleteScript } = scriptSlice.actions;
export const { sortScript, upsertScript, deleteScript, enableLoading, updateEnableStatus } = scriptSlice.actions;
export const { selectScripts } = scriptSlice.selectors;

View File

@ -0,0 +1,114 @@
import JSZip from "jszip";
import BackupExport from "./export";
import BackupImport from "./import";
import { BackupData } from "./struct";
import { describe, expect, it } from "vitest";
import { initTestEnv } from "@Tests/utils";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
initTestEnv();
describe("backup", () => {
const zipFile = new JSZip();
const fs = new ZipFileSystem(zipFile);
it("empty", async () => {
await new BackupExport(fs).export({
script: [],
subscribe: [],
});
const resp = await new BackupImport(fs).parse();
expect(resp).toEqual({
script: [],
subscribe: [],
});
});
it("export and import script", async () => {
const data: BackupData = {
script: [
{
code: `// ==UserScript==
// @name New Userscript
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// @match {{match}}
// ==/UserScript==
console.log('hello world')`,
options: {
options: {},
meta: {
name: "test",
modified: 1,
file_url: "",
},
settings: {
enabled: true,
position: 1,
},
},
resources: [
{
meta: { name: "test1", mimetype: "text/plain" },
base64: "data:text/plain;base64,aGVsbG8gd29ybGQ=",
source: "hello world",
},
],
requires: [
{
meta: { name: "test2", mimetype: "text/plain" },
base64: "data:text/plain;base64,aGVsbG8gd29ybGQ=",
source: "hello world",
},
],
requiresCss: [
{
meta: { name: "test3", mimetype: "application/javascript" },
base64: "data:application/javascript;base64,aGVsbG8gd29ybGQ=",
source: "hello world",
},
],
storage: {
ts: 1,
data: {
num: 1,
str: "data",
bool: false,
},
},
},
],
subscribe: [
{
source: `// ==UserSubscribe==
// @name New Usersubscribe
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// ==/UserSubscribe==
console.log('hello world')`,
options: {
meta: {
name: "test",
modified: 1,
url: "",
},
},
},
],
} as unknown as BackupData;
await new BackupExport(fs).export(data);
expect(data.script[0].storage.data.num).toEqual("n1");
expect(data.script[0].storage.data.str).toEqual("sdata");
expect(data.script[0].storage.data.bool).toEqual("bfalse");
const resp = await new BackupImport(fs).parse();
data.script[0].storage.data.num = 1;
data.script[0].storage.data.str = "data";
data.script[0].storage.data.bool = false;
expect(resp).toEqual(data);
});
});

94
src/pkg/backup/export.ts Normal file
View File

@ -0,0 +1,94 @@
import FileSystem from "@Pkg/filesystem/filesystem";
import crypto from "crypto-js";
import { base64ToBlob } from "../utils/script";
import { toStorageValueStr } from "../utils/utils";
import {
BackupData,
ResourceBackup,
ScriptBackupData,
SubscribeBackupData,
} from "./struct";
export default class BackupExport {
fs: FileSystem;
constructor(fileSystem: FileSystem) {
this.fs = fileSystem;
}
// 导出备份数据
export(data: BackupData): Promise<void> {
// 写入脚本备份
const results: Promise<void>[] = [];
data.script.forEach((item) => {
results.push(this.writeScript(item));
});
data.subscribe.forEach((item) => {
results.push(this.writeSubscribe(item));
});
return Promise.all(results).then(() => undefined);
}
async writeScript(script: ScriptBackupData) {
const { name } = script.options!.meta;
// 写脚本文件
await (await this.fs.create(`${name}.user.js`)).write(script.code);
// 写入脚本options.json
await (
await this.fs.create(`${name}.options.json`)
).write(JSON.stringify(script.options));
// 写入脚本storage.json
// 不想兼容tm的导出规则了,直接写入storage.json
const storage = { ...script.storage };
Object.keys(storage.data).forEach((key: string) => {
storage.data[key] = toStorageValueStr(storage.data[key]);
});
await (
await this.fs.create(`${name}.storage.json`)
).write(JSON.stringify(storage));
// 写入脚本资源文件
await this.writeResource(name, script.resources, "resources");
await this.writeResource(name, script.requires, "requires");
await this.writeResource(name, script.requiresCss, "requires.css");
return Promise.resolve();
}
async writeResource(
name: string,
resources: ResourceBackup[],
type: "resources" | "requires" | "requires.css"
): Promise<void[]> {
const results: Promise<void>[] = resources.map(async (item) => {
// md5是tm的导出规则
const md5 = crypto.MD5(`${type}{val.meta.url}`).toString();
if (item.source) {
await (
await this.fs.create(`${name}.user.js-${md5}-${item.meta.name}`)
).write(item.source!);
} else {
await (
await this.fs.create(`${name}.user.js-${md5}-${item.meta.name}`)
).write(base64ToBlob(item.base64));
}
(
await this.fs.create(
`${name}.user.js-${md5}-${item.meta.name}.${type}.json`
)
).write(JSON.stringify(item.meta));
});
return Promise.all(results);
}
async writeSubscribe(subscribe: SubscribeBackupData) {
const { name } = subscribe.options!.meta;
// 写入订阅文件
await (await this.fs.create(`${name}.user.sub.js`)).write(subscribe.source);
// 写入订阅options.json
await (
await this.fs.create(`${name}.user.sub.options.json`)
).write(JSON.stringify(subscribe.options));
return Promise.resolve();
}
}

238
src/pkg/backup/import.ts Normal file
View File

@ -0,0 +1,238 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import FileSystem, { File } from "@Pkg/filesystem/filesystem";
import { isText } from "../utils/istextorbinary";
import { blobToBase64 } from "../utils/script";
import { parseStorageValue } from "../utils/utils";
import {
BackupData,
ResourceBackup,
ResourceMeta,
ScriptBackupData,
ScriptOptionsFile,
SubscribeBackupData,
SubscribeOptionsFile,
ValueStorage,
} from "./struct";
import JSZip from "jszip";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
type ViolentmonkeyFile = {
scripts: {
[key: string]: {
config: {
enabled: boolean;
};
};
};
};
// 备份导入工具
export default class BackupImport {
fs: FileSystem;
logger: Logger;
constructor(fileSystem: FileSystem) {
this.fs = fileSystem;
this.logger = LoggerCore.logger({ component: "backupImport" });
}
// 解析出备份数据
async parse(): Promise<BackupData> {
const map = new Map<string, ScriptBackupData>();
const subscribe = new Map<string, SubscribeBackupData>();
let files = await this.fs.list();
// 处理订阅
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".user.sub.js")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 12);
const subData = {
source: await (await this.fs.open(file)).read(),
} as SubscribeBackupData;
subscribe.set(key, subData);
return Promise.resolve(true);
});
// 处理订阅options
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".user.sub.options.json")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 22);
const data = <SubscribeOptionsFile>JSON.parse(await (await this.fs.open(file)).read());
subscribe.get(key)!.options = data;
return Promise.resolve(true);
});
// 先处理*.user.js文件
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".user.js")) {
return Promise.resolve(false);
}
// 遍历与脚本同名的文件
const key = name.substring(0, name.length - 8);
const backupData = {
code: await (await this.fs.open(file)).read(),
storage: { data: {}, ts: 0 },
requires: [],
requiresCss: [],
resources: [],
} as ScriptBackupData;
map.set(key, backupData);
return Promise.resolve(true);
});
// 处理options.json文件
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".options.json")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 13);
const data = <ScriptOptionsFile>JSON.parse(await (await this.fs.open(file)).read());
map.get(key)!.options = data;
return Promise.resolve(true);
});
// 处理storage.json文件
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".storage.json")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 13);
const data = <ValueStorage>JSON.parse(await (await this.fs.open(file)).read());
Object.keys(data.data).forEach((dataKey) => {
data.data[dataKey] = parseStorageValue(data.data[dataKey]);
});
map.get(key)!.storage = data;
return Promise.resolve(true);
});
// 处理各种资源文件
// 将期望的资源文件名储存到map中, 以便后续处理
const resourceFilenameMap = new Map<
string,
{
index: number;
key: string;
type: "resources" | "requires" | "requiresCss";
}
>();
files = await this.dealFile(files, async (file) => {
const { name } = file;
const userJsIndex = name.indexOf(".user.js-");
if (userJsIndex === -1) {
return Promise.resolve(false);
}
const key = name.substring(0, userJsIndex);
let type: "resources" | "requires" | "requiresCss" | "" = "";
if (!name.endsWith(".resources.json")) {
if (!name.endsWith(".requires.json")) {
if (!name.endsWith(".requires.css.json")) {
return Promise.resolve(false);
}
type = "requiresCss";
resourceFilenameMap.set(name.substring(0, name.length - 18), {
index: map.get(key)!.requiresCss.length,
key,
type,
});
} else {
type = "requires";
resourceFilenameMap.set(name.substring(0, name.length - 14), {
index: map.get(key)!.requires.length,
key,
type,
});
}
} else {
type = "resources";
resourceFilenameMap.set(name.substring(0, name.length - 15), {
index: map.get(key)!.resources.length,
key,
type,
});
}
const data = <ResourceMeta>JSON.parse(await (await this.fs.open(file)).read());
map.get(key)![type].push({
meta: data,
} as never as ResourceBackup);
return Promise.resolve(true);
});
// 处理资源文件的内容
let violentmonkeyFile: File | undefined;
files = await this.dealFile(files, async (file) => {
if (file.name === "violentmonkey") {
violentmonkeyFile = file;
return Promise.resolve(true);
}
const info = resourceFilenameMap.get(file.name);
if (info === undefined) {
return Promise.resolve(false);
}
const resource = map.get(info.key)![info.type][info.index];
resource.base64 = await blobToBase64(await (await this.fs.open(file)).read("blob"));
if (resource.meta) {
// 存在meta
// 替换base64前缀
if (resource.meta.mimetype) {
resource.base64 = resource.base64.replace(/^data:.*?;base64,/, `data:${resource.meta.mimetype};base64,`);
}
if (isText(await (await this.fs.open(file)).read("blob"))) {
resource.source = await (await this.fs.open(file)).read();
}
}
return Promise.resolve(true);
});
files.length &&
this.logger.warn("unhandled files", {
num: files.length,
files: files.map((f) => f.name),
});
// 处理暴力猴导入资源
if (violentmonkeyFile) {
try {
const data = JSON.parse(await (await this.fs.open(violentmonkeyFile)).read("string")) as ViolentmonkeyFile;
// 设置开启状态
const keys = Object.keys(data.scripts);
keys.forEach((key) => {
const vioScript = data.scripts[key];
if (!vioScript.config.enabled) {
const script = map.get(key);
if (!script) {
return;
}
script.enabled = false;
}
});
} catch (e) {
this.logger.error("violentmonkey file parse error", Logger.E(e));
}
}
// 将map转化为数组
return Promise.resolve({
script: Array.from(map.values()),
subscribe: Array.from(subscribe.values()),
});
}
async dealFile(files: File[], handler: (file: File) => Promise<boolean>): Promise<File[]> {
const newFiles: File[] = [];
const results = await Promise.all(files.map(handler));
results.forEach((result, index) => {
if (!result) {
newFiles.push(files[index]);
}
});
return Promise.resolve(newFiles);
}
}

108
src/pkg/backup/struct.ts Normal file
View File

@ -0,0 +1,108 @@
/* eslint-disable camelcase */
export type ResourceMeta = {
name: string;
url: string;
ts: number;
mimetype?: string;
};
export type ResourceBackup = {
meta: ResourceMeta;
// text数据
source?: string;
// 二进制数据
base64: string;
};
export type ValueStorage = {
data: { [key: string]: any };
ts: number;
};
export type ScriptOptions = {
check_for_updates: boolean;
comment: string | null;
compat_foreach: boolean;
compat_metadata: boolean;
compat_prototypes: boolean;
compat_wrappedjsobject: boolean;
compatopts_for_requires: boolean;
noframes: boolean | null;
override: {
merge_connects: boolean;
merge_excludes: boolean;
merge_includes: boolean;
merge_matches: boolean;
orig_connects: Array<string>;
orig_excludes: Array<string>;
orig_includes: Array<string>;
orig_matches: Array<string>;
orig_noframes: boolean | null;
orig_run_at: string;
use_blockers: Array<string>;
use_connects: Array<string>;
use_excludes: Array<string>;
use_includes: Array<string>;
use_matches: Array<string>;
};
run_at: string | null;
};
export type ScriptMeta = {
name: string;
uuid: string; // 此uuid是对tm的兼容处理
sc_uuid: string; // 脚本猫uuid
modified: number;
file_url: string;
subscribe_url?: string;
};
export type ScriptOptionsFile = {
options: ScriptOptions;
settings: { enabled: boolean; position: number };
meta: ScriptMeta;
};
export type ScriptInfo = {
name: string;
code: string;
};
export type ScriptBackupData = {
code: string;
options?: ScriptOptionsFile;
storage: ValueStorage;
requires: ResourceBackup[];
requiresCss: ResourceBackup[];
resources: ResourceBackup[];
// 为了兼容暴力猴而设置的字段
enabled?: boolean;
};
export type SubscribeScript = {
uuid: string;
url: string;
};
export type SubscribeMeta = {
name: string;
modified: number;
url: string;
};
export type SubscribeOptionsFile = {
settings: { enabled: boolean };
scripts: { [key: string]: SubscribeScript };
meta: SubscribeMeta;
};
export type SubscribeBackupData = {
source: string;
options?: SubscribeOptionsFile;
};
export type BackupData = {
script: ScriptBackupData[];
subscribe: SubscribeBackupData[];
};

10
src/pkg/backup/utils.ts Normal file
View File

@ -0,0 +1,10 @@
import ZipFileSystem from "@Packages/filesystem/zip/zip";
import JSZip from "jszip";
import BackupImport from "./import";
// 解析备份文件
export function parseBackupZipFile(zip: JSZip) {
const fs = new ZipFileSystem(zip);
// 解析文件
return new BackupImport(fs).parse();
}

View File

@ -5,6 +5,7 @@ import { FileSystemType } from "@Packages/filesystem/factory";
import { MessageQueue } from "@Packages/message/message_queue";
import i18n from "@App/locales/locales";
import dayjs from "dayjs";
import { ExtVersion } from "@App/app/const";
export const SystamConfigChange = "systemConfigChange";
@ -33,6 +34,16 @@ export class SystemConfig {
});
}
addListener(key: string, callback: (value: any) => void) {
this.mq.subscribe(SystamConfigChange, (data: { key: string; value: string }) => {
if (data.key !== key) {
return;
}
const { value } = data;
callback(value);
});
}
async getAll(): Promise<{ [key: string]: any }> {
const ret: { [key: string]: any } = {};
const list = await this.storage.keys();
@ -217,18 +228,19 @@ export class SystemConfig {
this.set("menu_expand_num", val);
}
async getLanguage() {
const defaultLanguage = await new Promise<string>((resolve) => {
chrome.i18n.getAcceptLanguages((lngs) => {
// 遍历数组寻找匹配语言
for (let i = 0; i < lngs.length; i += 1) {
const lng = lngs[i];
if (i18n.hasResourceBundle(lng, "translation")) {
resolve(lng);
break;
}
async getLanguage(acceptLanguages?: string[]): Promise<string> {
const defaultLanguage = await new Promise<string>(async (resolve) => {
if (!acceptLanguages) {
acceptLanguages = await chrome.i18n.getAcceptLanguages();
}
// 遍历数组寻找匹配语言
for (let i = 0; i < acceptLanguages.length; i += 1) {
const lng = acceptLanguages[i];
if (i18n.hasResourceBundle(lng, "translation")) {
resolve(lng);
break;
}
});
}
});
return this.get("language", defaultLanguage || chrome.i18n.getUILanguage());
}
@ -238,4 +250,28 @@ export class SystemConfig {
i18n.changeLanguage(value);
dayjs.locale(value.toLocaleLowerCase());
}
setCheckUpdate(data: { notice: string; version: string; isRead: boolean }) {
this.set("check_update", {
notice: data.notice,
version: data.version,
isRead: data.isRead,
});
}
getCheckUpdate(): Promise<Parameters<typeof this.setCheckUpdate>[0]> {
return this.get("check_update", {
notice: "",
isRead: false,
version: ExtVersion,
});
}
setEnableScript(enable: boolean) {
this.set("enable_script", enable);
}
getEnableScript(): Promise<boolean> {
return this.get("enable_script", true);
}
}

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { dealPatternMatches, parsePatternMatchesURL, UrlMatch } from "./match";
import path from "path";
// https://developer.chrome.com/docs/extensions/mv3/match_patterns/
describe("UrlMatch-google", () => {
@ -39,16 +40,10 @@ describe("UrlMatch-google", () => {
describe("UrlMatch-google-error", () => {
const url = new UrlMatch<string>();
it("error-1", () => {
expect(() => {
url.add("https://*foo/bar", "ok1");
}).toThrow(Error);
});
// 从v0.17.0开始允许这种
it("error-2", () => {
url.add("https://foo.*.bar/baz", "ok1");
expect(url.match("https://foo.api.bar/baz")).toEqual(["ok1"]);
});
it("error-3", () => {
it("error-2", () => {
expect(() => {
url.add("http:/bar", "ok1");
}).toThrow(Error);
@ -77,6 +72,13 @@ describe("UrlMatch-search", () => {
expect(url.match("http://api.bar.example.com/")).toEqual(["ok1"]);
expect(url.match("http://api.example.com/")).toEqual([]);
});
it("*://example*/*/example.path*", () => {
const url = new UrlMatch<string>();
url.add("*://example*/*/example.path*", "ok1");
expect(url.match("https://example.com/foo/example.path")).toEqual(["ok1"]);
expect(url.match("https://example.com/foo/bar/example.path")).toEqual(["ok1"]);
expect(url.match("https://example.com/foo/bar/example.path2")).toEqual(["ok1"]);
});
});
describe("UrlMatch-port1", () => {
@ -177,6 +179,12 @@ describe("parsePatternMatchesURL", () => {
host: "127.0.0.1",
path: "",
});
const matches4 = parsePatternMatchesURL("*://*/*");
expect(matches4).toEqual({
scheme: "*",
host: "*",
path: "*",
});
});
it("search", () => {
// 会忽略掉search部分
@ -191,7 +199,7 @@ describe("parsePatternMatchesURL", () => {
const matches = parsePatternMatchesURL("*://www.example.com*");
expect(matches).toEqual({
scheme: "*",
host: "www.example.com",
host: "*",
path: "*",
});
});
@ -203,4 +211,24 @@ describe("parsePatternMatchesURL", () => {
path: "*",
});
});
it("一些怪异的情况", () => {
let matches = parsePatternMatchesURL("*://*./*");
expect(matches).toEqual({
scheme: "*",
host: "*",
path: "*",
});
matches = parsePatternMatchesURL("*://example*/*");
expect(matches).toEqual({
scheme: "*",
host: "*",
path: "*",
});
matches = parsePatternMatchesURL("http*://*.example.com/*");
expect(matches).toEqual({
scheme: "*",
host: "*.example.com",
path: "*",
});
});
});

View File

@ -64,20 +64,11 @@ export default class Match<T> {
let pos = u.host.indexOf("*");
if (u.host === "*" || u.host === "**") {
pos = -1;
} else if (u.host.endsWith("*")) {
// 处理*结尾
if (!u.host.endsWith(":*")) {
u.host = u.host.substring(0, u.host.length - 1);
}
}
u.host = u.host.replace(/\*/g, "[^/]*?");
// 处理 *.开头
if (u.host.startsWith("[^/]*?.")) {
u.host = `([^/]*?\\.?)${u.host.substring(7)}`;
} else if (pos !== -1) {
if (u.host.indexOf(".") === -1) {
return "";
}
}
// 处理顶域
if (u.host.endsWith("tld")) {
@ -223,6 +214,7 @@ export interface PatternMatchesUrl {
}
// 解析URL, 根据https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns?hl=zh-cn进行处理
// 将一些异常情况直接转为通配用最大的范围去注册userScript在执行的时候再用UrlMatch去匹配过滤
export function parsePatternMatchesURL(
url: string,
options?: {
@ -251,6 +243,9 @@ export function parsePatternMatchesURL(
}
}
if (result) {
if (result.scheme === "http*") {
result.scheme = "*";
}
if (result.host !== "*") {
// *开头但是不是*.的情况
if (result.host.startsWith("*")) {
@ -261,6 +256,10 @@ export function parsePatternMatchesURL(
}
// 结尾是*的情况
if (result.host.endsWith("*")) {
result.host = "*";
}
// 结尾是.的情况
if (result.host.endsWith(".")) {
result.host = result.host.slice(0, -1);
}
// 处理 www.*.example.com 的情况为 *.example.com

View File

@ -251,3 +251,27 @@ export function calculateMd5(blob: Blob) {
};
});
}
export function errorMsg(e: any): string {
if (typeof e === "string") {
return e;
}
if (e instanceof Error) {
return e.message;
}
if (typeof e === "object") {
return JSON.stringify(e);
}
return "";
}
export function isUserScriptsAvailable() {
try {
// Property access which throws if developer mode is not enabled.
chrome.userScripts;
return true;
} catch {
// Not available.
return false;
}
}

View File

@ -1,5 +1,4 @@
import ServiceWorkerManager from "./app/service/service_worker";
import migrate from "./app/migrate";
import LoggerCore from "./app/logger/core";
import DBWriter from "./app/logger/db_writer";
import { LoggerDAO } from "./app/repo/logger";
@ -7,8 +6,8 @@ import { ExtensionMessage } from "@Packages/message/extension_message";
import { Server } from "@Packages/message/server";
import { MessageQueue } from "@Packages/message/message_queue";
import { ServiceWorkerMessageSend } from "@Packages/message/window_message";
import migrate from "./app/migrate";
// 初始化数据库
migrate();
const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html";
@ -49,14 +48,14 @@ async function setupOffscreenDocument() {
}
async function main() {
// 初始化管理器
const message = new ExtensionMessage(true);
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
labels: { env: "service_worker" },
});
loggerCore.logger().debug("service worker start");
// 初始化管理器
const message = new ExtensionMessage(true);
const server = new Server("serviceWorker", message);
const messageQueue = new MessageQueue();
const manager = new ServiceWorkerManager(server, messageQueue, new ServiceWorkerMessageSend());

View File

@ -139,21 +139,6 @@ declare function GM_cookie(
ondone: (cookie: GMTypes.Cookie[], error: unknown | undefined) => void
): void;
/**
* 可以通过GM_addValueChangeListener获取tabid
* 再通过tabid(前后端通信可能用到,ValueChangeListener会返回tabid),获取storeid,后台脚本用.
* 请注意这是一个实验性质的API,后续可能会改变
* @param tabid 页面的tabid
* @param ondone 完成事件
* @param callback.storeid 该页面的storeid,可以给GM_cookie使用
* @param callback.error 错误信息
* @deprecated 已废弃,请使用GM_cookie("store", tabid)替代
*/
declare function GM_getCookieStore(
tabid: number,
ondone: (storeId: number | undefined, error: unknown | undefined) => void
): void;
/**
* 设置浏览器代理
* @deprecated 正式版中已废弃,后续可能会在beta版本中添加
@ -289,10 +274,7 @@ declare namespace CATType {
}
declare namespace GMTypes {
/*
* store为获取隐身窗口之类的cookie,API,
*/
type CookieAction = "list" | "delete" | "set" | "store";
type CookieAction = "list" | "delete" | "set";
type LoggerLevel = "debug" | "info" | "warn" | "error";
@ -308,17 +290,13 @@ declare namespace GMTypes {
path?: string;
secure?: boolean;
session?: boolean;
storeId?: string;
httpOnly?: boolean;
expirationDate?: number;
// store用
tabId?: number;
}
interface Cookie {
domain: string;
name: string;
storeId: string;
value: string;
session: boolean;
hostOnly: boolean;
@ -341,7 +319,7 @@ declare namespace GMTypes {
active?: boolean;
insert?: boolean;
setParent?: boolean;
useOpen?: boolean; // //Firefox的功能
useOpen?: boolean; // //Firefox的功能 使window.open打开新窗口 #178
}
interface XHRResponse {
@ -384,7 +362,7 @@ declare namespace GMTypes {
user?: string;
password?: string;
nocache?: boolean;
maxRedirects?: number;
redirect?: "follow" | "error" | "manual";// tm保持一致, v0.17.0maxRedirects, 使redirect替代, 使fetch模式
onload?: Listener<XHRResponse>;
onloadstart?: Listener<XHRResponse>;

1
src/types/main.d.ts vendored
View File

@ -31,7 +31,6 @@ declare namespace GMSend {
nocache?: boolean;
dataType?: "FormData" | "Blob";
redirect?: "follow" | "error" | "manual";
maxRedirects?: number; // 为了与tm保持一致, 在v0.17.0后废弃, 使用redirect替代
}
interface XHRFormData {

View File

@ -362,7 +362,7 @@ declare namespace GMTypes {
user?: string;
password?: string;
nocache?: boolean;
maxRedirects?: number;
redirect?: "follow" | "error" | "manual";// 为了与tm保持一致, 在v0.17.0后废弃maxRedirects, 使用redirect替代, 会强制使用fetch模式
onload?: Listener<XHRResponse>;
onloadstart?: Listener<XHRResponse>;