Compare commits

...

9 Commits

Author SHA1 Message Date
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
48 changed files with 2217 additions and 202 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

@ -1,6 +1,6 @@
{
"name": "scriptcat",
"version": "0.17.0-alpha.1",
"version": "0.17.0-alpha.2",
"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 {
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) => {
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`,

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

@ -1,8 +1,9 @@
import { db } from "./repo/dao";
import { Script } from "./repo/scripts";
import { Script, ScriptAndCode, ScriptCodeDAO, ScriptDAO } from "./repo/scripts";
import { Subscribe, SubscribeDAO } from "./repo/subscribe";
// 0.10.0重构,重命名字段,统一使用小峰驼
function renameField(): void {
function renameField() {
db.version(16)
.stores({
scripts:
@ -33,9 +34,100 @@ function renameField(): void {
export: "++id,&scriptId",
});
// 将脚本数据迁移到chrome.storage
// db.version(18)
// .stores({})
// .upgrade((tx) => {});
db.version(18).upgrade(async (tx) => {
// 迁移脚本
const scripts = await tx.table("scripts").toArray();
const scriptDAO = new ScriptDAO();
const scriptCodeDAO = new ScriptCodeDAO();
await Promise.all(
scripts.map((script: ScriptAndCode) => {
const {
uuid,
name,
namespace,
author,
originDomain,
subscribeUrl,
type,
sort,
status,
runStatus,
metadata,
createtime,
checktime,
code,
} = script;
return scriptDAO
.save({
uuid,
name,
namespace,
author,
originDomain,
subscribeUrl,
type,
sort,
status,
runStatus,
metadata,
createtime,
checktime,
})
.then((s) =>
scriptCodeDAO.save({
uuid: s.uuid,
code,
})
);
})
);
// 迁移订阅
const subscribe = await tx.table("subscribe").toArray();
const subscribeDAO = new SubscribeDAO();
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,
});
})
);
// 迁移value
interface MV2Value {
id: number;
scriptId: number;
storageName?: string;
key: string;
value: any;
createtime: number;
updatetime: number;
}
const values = await tx.table("value").toArray();
const valueDAO = new ScriptCodeDAO();
await Promise.all(
values.map((v) => {
const { scriptId, storageName, key, value, createtime } = v;
return valueDAO.save({
scriptId,
storageName,
key,
value,
createtime,
});
})
);
// 迁移permission
});
return db.open();
}
export default function migrate() {
@ -90,7 +182,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 +205,5 @@ export default function migrate() {
permission: "++id,scriptId,[scriptId+permission+permissionValue],createtime,updatetime",
});
// 使用小峰驼统一命名规范
renameField();
return renameField();
}

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

@ -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

@ -54,5 +54,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,11 @@ 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";
export class ServiceWorkerClient extends Client {
constructor(msg: MessageSend) {
@ -150,3 +155,56 @@ export class PermissionClient extends Client {
return this.do("getInfo", 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

@ -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,8 @@ 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";
export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode";
@ -24,6 +26,7 @@ export default class ServiceWorkerManager {
await this.sender.init();
this.mq.emit("preparationOffscreen", {});
});
this.sender.init();
const systemConfig = new SystemConfig(this.mq);
@ -32,11 +35,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 +67,33 @@ 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;
}
});
// 监听配置变化
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);
});
}
}

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);
// 资源不存在,重新加载

View File

@ -26,8 +26,9 @@ 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";
// 为了优化性能存储到缓存时删除了code与value
// 为了优化性能存储到缓存时删除了code、value与resource
export interface ScriptMatchInfo extends ScriptRunResouce {
matches: string[];
excludeMatches: string[];
@ -54,7 +55,8 @@ export class RuntimeService {
private sender: MessageSend,
private mq: MessageQueue,
private value: ValueService,
private script: ScriptService
private script: ScriptService,
private resource: ResourceService
) {}
async init() {
@ -196,8 +198,7 @@ export class RuntimeService {
// 匹配当前页面的脚本
const matchScriptUuid = await this.getPageScriptUuidByUrl(chromeSender.url!);
const scripts = await Promise.all(
matchScriptUuid.map(async (uuid): Promise<undefined | ScriptRunResouce> => {
const scripts = matchScriptUuid.map((uuid) => {
const scriptRes = Object.assign({}, this.scriptMatchCache?.get(uuid));
// 判断脚本是否开启
if (scriptRes.status === SCRIPT_STATUS_DISABLE) {
@ -211,18 +212,22 @@ export class RuntimeService {
}
// 获取value
return scriptRes;
})
);
});
const enableScript = scripts.filter((item) => item);
const enableScript = scripts.filter((item) => item) as ScriptMatchInfo[];
await Promise.all([
// 加载value
await Promise.all(
enableScript.map(async (script) => {
...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,
@ -325,8 +330,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);
}

View File

@ -59,7 +59,7 @@ export class ScriptService {
// 读取脚本url内容, 进行安装
const logger = this.logger.with({ url: targetUrl });
logger.debug("install script");
this.openInstallPageByUrl(targetUrl).catch((e) => {
this.openInstallPageByUrl(targetUrl, "user").catch((e) => {
logger.error("install script error", Logger.E(e));
// 如果打开失败, 则重定向到安装页
chrome.scripting.executeScript({
@ -135,18 +135,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 +192,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) => {
@ -330,7 +343,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 +406,16 @@ export class ScriptService {
});
}
checkScriptUpdate() {
this.scriptDAO.all().then(async (scripts) => {
async checkScriptUpdate() {
const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle();
if (!checkCycle) {
return;
}
const check = await this.systemConfig.getUpdateDisableScript();
this.scriptDAO.all().then(async (scripts) => {
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;
}
// 检查是否符合

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

@ -364,5 +364,8 @@
"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": "获取备份文件失败"
}

View File

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

View File

@ -4,7 +4,6 @@ import DBWriter from "./app/logger/db_writer";
import { LoggerDAO } from "./app/repo/logger";
import { OffscreenManager } from "./app/service/offscreen";
// 初始化数据库
migrate();
function main() {

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

@ -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";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
labels: { env: "install" },
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 { LoggerDAO } from "@App/app/repo/logger.ts";
import DBWriter from "@App/app/logger/db_writer.ts";
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
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,12 +8,9 @@ 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";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),

View File

@ -8,15 +8,12 @@ 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";
// 初始化数据库
migrate();
registerEditor();
// 初始化日志组件
const loggerCore = new LoggerCore({

View File

@ -83,7 +83,7 @@ import {
scriptClient,
} 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 };
@ -719,7 +719,17 @@ function ScriptList() {
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,45 +1,25 @@
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) => {
dao.all().then((subscribes) => {
setList(subscribes);
});
}, []);
@ -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,19 +65,15 @@ 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;
});
} else {
p = subscribeCtrl.disable(item.id).then(() => {
list[index].status = SUBSCRIBE_STATUS_DISABLE;
});
}
p.catch((err) => {
subscribeClient
.enable(item.url, checked)
.then(() => {
list[index].status = checked ? SUBSCRIBE_STATUS_ENABLE : SUBSCRIBE_STATUS_DISABLE;
})
.catch((err) => {
Message.error(err);
}).finally(() => {
})
.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,8 +242,13 @@ function SubscribeList() {
title={t("confirm_delete_subscription")}
icon={<RiDeleteBin5Fill />}
onOk={() => {
setList(list.filter((val) => val.id !== item.id));
subscribeCtrl.delete(item.id).catch((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}`);
});
}}

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] = await Promise.all([
systemConfig.getBackup(),
systemConfig.getVscodeUrl(),
systemConfig.getVscodeReconnect(),
]);
setFilesystemType(backup.filesystem);
setFilesystemParam(backup.params[backup.filesystem] || {});
});
setVscodeUrl(vscodeUrl);
setVscodeReconnect(systemConfig.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

@ -2,7 +2,6 @@ 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";
@ -12,8 +11,6 @@ import "./index.css";
import { Provider } from "react-redux";
import { store } from "../store/store.ts";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),

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 () => {
// 排序

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

@ -33,6 +33,13 @@ export class SystemConfig {
});
}
addListener(key: string, callback: (value: any) => void) {
this.mq.subscribe(key, (msg) => {
const { value } = msg;
callback(value);
});
}
async getAll(): Promise<{ [key: string]: any }> {
const ret: { [key: string]: any } = {};
const list = await this.storage.keys();
@ -58,7 +65,9 @@ export class SystemConfig {
public set(key: string, val: any) {
this.cache.set(key, val);
this.storage.set(key, val);
this.storage.set(key, val).then(() => {
console.log(chrome.runtime.lastError, val);
});
// 发送消息通知更新
this.mq.publish(SystamConfigChange, {
key,

View File

@ -251,3 +251,16 @@ 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 "";
}

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";