This commit is contained in:
2024-11-23 21:13:05 +08:00
parent 6693de3f35
commit 82e2c29937
29 changed files with 1819 additions and 27 deletions

View File

@ -16,13 +16,17 @@
"lint-fix": "eslint --fix ."
},
"dependencies": {
"cron": "^3.2.1",
"dayjs": "^1.11.13",
"dexie": "^4.0.10",
"eventemitter3": "^5.0.1",
"i18next": "^23.16.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.1.0",
"uuid": "^11.0.3"
"semver": "^7.6.3",
"uuid": "^11.0.3",
"yaml": "^2.6.1"
},
"devDependencies": {
"@eslint/compat": "^1.2.0",
@ -32,6 +36,7 @@
"@types/chrome": "^0.0.279",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/semver": "^7.5.8",
"@vitest/coverage-v8": "2.1.4",
"cross-env": "^7.0.3",
"eslint": "^9.12.0",

51
pnpm-lock.yaml generated
View File

@ -8,9 +8,15 @@ importers:
.:
dependencies:
cron:
specifier: ^3.2.1
version: 3.2.1
dayjs:
specifier: ^1.11.13
version: 1.11.13
dexie:
specifier: ^4.0.10
version: 4.0.10
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
@ -26,9 +32,15 @@ importers:
react-i18next:
specifier: ^15.1.0
version: 15.1.0(i18next@23.16.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
semver:
specifier: ^7.6.3
version: 7.6.3
uuid:
specifier: ^11.0.3
version: 11.0.3
yaml:
specifier: ^2.6.1
version: 2.6.1
devDependencies:
'@eslint/compat':
specifier: ^1.2.0
@ -51,6 +63,9 @@ importers:
'@types/react-dom':
specifier: ^18.2.18
version: 18.3.1
'@types/semver':
specifier: ^7.5.8
version: 7.5.8
'@vitest/coverage-v8':
specifier: 2.1.4
version: 2.1.4(vitest@2.1.4(@types/node@22.8.1)(jsdom@25.0.1))
@ -767,6 +782,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@ -797,6 +815,9 @@ packages:
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
'@types/send@0.17.4':
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
@ -1152,6 +1173,9 @@ packages:
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cron@3.2.1:
resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==}
cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@ -1257,6 +1281,9 @@ packages:
detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
dexie@4.0.10:
resolution: {integrity: sha512-eM2RzuR3i+M046r2Q0Optl3pS31qTWf8aFuA7H9wnsHTwl8EPvroVLwvQene/6paAs39Tbk6fWZcn2aZaHkc/w==}
diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
@ -1937,6 +1964,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
magic-string@0.30.12:
resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==}
@ -2881,6 +2912,11 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yaml@2.6.1:
resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==}
engines: {node: '>= 14'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@ -3373,6 +3409,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/luxon@3.4.2': {}
'@types/mime@1.3.5': {}
'@types/node-forge@1.3.11':
@ -3402,6 +3440,8 @@ snapshots:
'@types/retry@0.12.2': {}
'@types/semver@7.5.8': {}
'@types/send@0.17.4':
dependencies:
'@types/mime': 1.3.5
@ -3841,6 +3881,11 @@ snapshots:
create-require@1.1.1: {}
cron@3.2.1:
dependencies:
'@types/luxon': 3.4.2
luxon: 3.5.0
cross-env@7.0.3:
dependencies:
cross-spawn: 7.0.3
@ -3931,6 +3976,8 @@ snapshots:
detect-node@2.1.0: {}
dexie@4.0.10: {}
diff@4.0.2: {}
dns-packet@5.6.1:
@ -4756,6 +4803,8 @@ snapshots:
lru-cache@10.4.3: {}
luxon@3.5.0: {}
magic-string@0.30.12:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@ -5804,6 +5853,8 @@ snapshots:
y18n@5.0.8: {}
yaml@2.6.1: {}
yargs-parser@21.1.1: {}
yargs@17.6.2:

View File

@ -39,6 +39,9 @@ export default defineConfig({
"@App": path.resolve(__dirname, "src/"),
"@Packages": path.resolve(__dirname, "packages/"),
},
fallback: {
child_process: false,
},
},
module: {
rules: [

42
src/app/cache.ts Normal file
View File

@ -0,0 +1,42 @@
export default class Cache {
static instance: Cache = new Cache();
static getInstance(): Cache {
return Cache.instance;
}
map: Map<string, unknown>;
private constructor() {
this.map = new Map<string, unknown>();
}
public get(key: string): unknown {
return this.map.get(key);
}
public async getOrSet(key: string, set: () => Promise<unknown>): Promise<unknown> {
let ret = this.get(key);
if (!ret) {
ret = await set();
this.set(key, ret);
}
return Promise.resolve(ret);
}
public set(key: string, value: unknown): void {
this.map.set(key, value);
}
public has(key: string): boolean {
return this.map.has(key);
}
public del(key: string): void {
this.map.delete(key);
}
public list(): string[] {
return Array.from(this.map.keys());
}
}

43
src/app/cache_key.ts Normal file
View File

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

50
src/app/logger/core.ts Normal file
View File

@ -0,0 +1,50 @@
import EventEmitter from "eventemitter3";
import Logger from "./logger";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogLabel {
[key: string]: string | string[] | boolean | number | undefined;
component?: string;
}
// 储存
export interface Writer {
write(level: LogLevel, message: string, label: LogLabel): void;
}
export default class LoggerCore {
static instance: LoggerCore;
static getInstance() {
return LoggerCore.instance;
}
static getLogger(...label: LogLabel[]) {
return LoggerCore.getInstance().logger(...label);
}
writer: Writer;
level: LogLevel = "info";
debug: boolean = false;
labels: LogLabel;
constructor(config: { level?: LogLevel; debug?: boolean; writer: Writer; labels: LogLabel }) {
this.writer = config.writer;
this.level = config.level || this.level;
this.debug = config.debug || this.debug;
this.labels = config.labels || {};
if (!LoggerCore.instance) {
LoggerCore.instance = this;
}
}
logger(...label: LogLabel[]) {
return new Logger(this, this.labels, ...label);
}
static EE = new EventEmitter();
}

View File

@ -0,0 +1,21 @@
import { LoggerDAO } from "../repo/logger";
import { LogLabel, LogLevel, Writer } from "./core";
// 使用indexdb作为日志存储
export default class DBWriter implements Writer {
dao: LoggerDAO;
constructor(dao: LoggerDAO) {
this.dao = dao;
}
write(level: LogLevel, message: string, label: LogLabel): void {
this.dao.save({
id: 0,
level,
message,
label,
createtime: new Date().getTime(),
});
}
}

92
src/app/logger/logger.ts Normal file
View File

@ -0,0 +1,92 @@
/* eslint-disable no-console */
import dayjs from "dayjs";
import LoggerCore, { LogLabel, LogLevel } from "./core";
const levelNumber = {
debug: 10,
info: 100,
warn: 1000,
error: 10000,
};
function buildLabel(...label: LogLabel[][]): LogLabel {
const ret: LogLabel = {};
label.forEach((item) => {
item.forEach((item2) => {
Object.keys(item2).forEach((key) => {
ret[key] = item2[key];
});
});
});
return ret;
}
export default class Logger {
core: LoggerCore;
label: LogLabel[];
constructor(core: LoggerCore, ...label: LogLabel[]) {
this.core = core;
this.label = label;
}
log(level: LogLevel, message: string, ...label: LogLabel[]) {
if (levelNumber[level] >= levelNumber[this.core.level]) {
this.core.writer.write(level, message, buildLabel(this.label, label));
}
if (this.core.debug) {
if (typeof message === "object") {
message = JSON.stringify(message);
}
const msg = `${dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss")} [${level}] msg=${message} label=${JSON.stringify(
buildLabel(this.label, label)
)}`;
switch (level) {
case "error":
console.error(msg);
break;
case "warn":
console.warn(msg);
break;
default:
console.info(msg);
break;
}
}
LoggerCore.EE.emit("log", { level, message, label });
}
with(...label: LogLabel[]) {
return new Logger(this.core, ...this.label, ...label);
}
debug(message: string, ...label: LogLabel[]) {
this.log("debug", message, ...label);
}
info(message: string, ...label: LogLabel[]) {
this.log("info", message, ...label);
}
warn(message: string, ...label: LogLabel[]) {
this.log("warn", message, ...label);
}
error(message: string, ...label: LogLabel[]) {
this.log("error", message, ...label);
}
static E(e: unknown): LogLabel {
if (typeof e === "string") {
return { error: e };
}
if (e instanceof Error) {
return { error: e.message };
}
if (typeof e === "object") {
return e as never;
}
return {};
}
}

View File

@ -0,0 +1,29 @@
import MessageCenter from "../message/center";
import { MessageManager } from "../message/message";
import { Logger, LoggerDAO } from "../repo/logger";
import { LogLabel, LogLevel, Writer } from "./core";
// 通过通讯机制写入日志
export default class MessageWriter implements Writer {
connect: MessageManager;
constructor(connect: MessageManager) {
this.connect = connect;
}
write(level: LogLevel, message: string, label: LogLabel): void {
this.connect.send("log", {
id: 0,
level,
message,
label,
createtime: new Date().getTime(),
});
}
}
export function ListenerMessage(db: LoggerDAO, connect: MessageCenter) {
connect.setHandler("log", (action, data: Logger) => {
db.save(data);
});
}

112
src/app/migrate.ts Normal file
View File

@ -0,0 +1,112 @@
import { db } from "./repo/dao";
import { Script } from "./repo/scripts";
// 0.10.0重构,重命名字段,统一使用小峰驼
function renameField(): void {
db.version(16)
.stores({
scripts:
"++id,&uuid,name,namespace,author,originDomain,subscribeUrl,type,sort,status," +
"runStatus,createtime,updatetime,checktime",
logger: "++id,level,createtime",
// export: "++id,&scriptId",
})
.upgrade(async (tx) => {
await tx.table("export").clear();
return tx
.table("scripts")
.toCollection()
.modify((script: { [key: string]: any }) => {
if (script.origin_domain) {
script.originDomain = script.origin_domain;
}
if (script.checkupdate_url) {
script.checkUpdateUrl = script.checkupdate_url;
}
if (script.download_url) {
script.downloadUrl = script.download_url;
}
});
});
db.version(17).stores({
// export是0.10.x时的兼容性处理
export: "++id,&scriptId",
});
}
export default function migrate() {
// 数据库索引定义,每一次变动必须更新version
db.version(1).stores({
scripts: "++id,&uuid,name,namespace,author,origin_domain,type,status,createtime,updatetime,checktime",
});
db.version(2).stores({
logger: "++id,level,origin,createtime",
permission: "++id,[scriptId+permission+permissionValue],createtime,updatetime",
});
db.version(3).stores({
logger: "++id,level,title,origin,createtime",
});
db.version(4).stores({
value: "++id,scriptId,namespace,key,createtime",
});
db.version(5).stores({
logger: "++id,level,origin,createtime,title,[origin+title],[level+origin+title]",
});
db.version(6).stores({
scripts: "++id,&uuid,name,namespace,author,origin_domain,type,status,runStatus,createtime,updatetime,checktime",
});
db.version(7).stores({
resource: "++id,&url,content,type,createtime,updatetime",
resourceLink: "++id,url,scriptId,createtime",
});
db.version(8).stores({
logger: "++id,level,origin,createtime",
});
db.version(9).stores({
logger: "++id,level,scriptId,origin,createtime",
});
db.version(10)
.stores({
scripts:
"++id,&uuid,name,namespace,author,origin_domain,type,sort,status,runStatus,createtime,updatetime,checktime",
})
.upgrade((tx) => {
return tx
.table("scripts")
.toCollection()
.modify((script: Script) => {
script.sort = 0;
});
});
db.version(11).stores({
export: "++id,&uuid,scriptId",
});
db.version(12)
.stores({
value: "++id,scriptId,storageName,key,createtime",
})
.upgrade((tx) => {
tx.table("value")
.toCollection()
.modify((value) => {
if (value.namespace) {
value.storageName = value.namespace;
delete value.namespace;
}
});
});
db.version(13).stores({
subscribe: "++id,&url,createtime,updatetime,checktime",
scripts:
"++id,&uuid,name,namespace,author,origin_domain,subscribeUrl,type,sort,status,runStatus,createtime,updatetime,checktime",
sync: "++id,&key,[user+device+type],createtime",
});
db.version(14).stores({
value: "++id,[scriptId+key],[storageName+key]",
});
db.version(15).stores({
permission: "++id,scriptId,[scriptId+permission+permissionValue],createtime,updatetime",
});
// 使用小峰驼统一命名规范
renameField();
}

62
src/app/repo/dao.test.ts Normal file
View File

@ -0,0 +1,62 @@
import "fake-indexeddb/auto";
import { DAO, db } from "./dao";
import { LoggerDAO } from "./logger";
import migrate from "../migrate";
migrate();
interface Test {
id: number;
data: string;
}
db.version(17).stores({ test: "++id,data" });
class testDAO extends DAO<Test> {
public tableName = "test";
constructor() {
super();
this.table = db.table(this.tableName);
}
}
describe("dao", () => {
const dao = new testDAO();
it("测试save", async () => {
expect(await dao.save({ id: 0, data: "ok1" })).toEqual(1);
expect(await dao.save({ id: 0, data: "ok" })).toEqual(2);
expect(await dao.save({ id: 2, data: "ok2" })).toEqual(2);
});
it("测试find", async () => {
expect(await dao.findOne({ id: 1 })).toEqual({ id: 1, data: "ok1" });
expect(await dao.findById(2)).toEqual({ id: 2, data: "ok2" });
});
it("测试list", async () => {
expect(await dao.list({ id: 1 })).toEqual([{ id: 1, data: "ok1" }]);
});
it("测试delete", async () => {
expect(await dao.delete({ id: 1 })).toEqual(1);
expect(await dao.findById(1)).toEqual(undefined);
});
});
describe("model", () => {
const logger = new LoggerDAO();
it("save", async () => {
expect(
await logger.save({
id: 0,
level: "info",
message: "ok",
label: {},
createtime: new Date().getTime(),
})
).toEqual(1);
});
});

105
src/app/repo/dao.ts Normal file
View File

@ -0,0 +1,105 @@
import Dexie from "dexie";
export const db = new Dexie("ScriptCat");
export const ErrSaveError = new Error("数据保存失败");
export class Page {
protected Page: number;
protected Count: number;
protected Order: string;
protected Sort: "asc" | "desc";
constructor(page: number, count: number, sort?: "asc" | "desc", order?: string) {
this.Page = page;
this.Count = count;
this.Order = order || "id";
this.Sort = sort || "desc";
}
public page() {
return this.Page;
}
public count() {
return this.Count;
}
public order() {
return this.Order;
}
public sort() {
return this.Sort;
}
}
export abstract class DAO<T> {
public table!: Dexie.Table<T, number>;
public tableName = "";
public list(query: { [key: string]: any }, page?: Page) {
if (!page) {
return this.table.where(query).toArray();
}
let collect = this.table
.where(query)
.offset((page.page() - 1) * page.count())
.limit(page.count());
if (page.order() !== "id") {
collect.sortBy(page.order());
}
if (page.sort() === "desc") {
collect = collect.reverse();
}
return collect.toArray();
}
public find() {
return this.table;
}
public findOne(where: { [key: string]: any }) {
return this.table.where(where).first();
}
public async save(val: T) {
const id = <number>(<any>val).id;
if (!id) {
delete (<any>val).id;
return this.table.add(val);
}
const resp = await this.table.update(id, <any>val);
if (resp) {
return Promise.resolve(id);
}
return Promise.reject(ErrSaveError);
}
public findById(id: number) {
return this.table.get(id);
}
public clear() {
return this.table.clear();
}
public async delete(id: number | { [key: string]: any }) {
if (typeof id === "number") {
return this.table.where({ id }).delete();
}
return this.table.where(id).delete();
}
public update(id: number, changes: { [key: string]: any }) {
return this.table.update(id, changes);
}
public count() {
return this.table.count();
}
}

28
src/app/repo/export.ts Normal file
View File

@ -0,0 +1,28 @@
import { ExportParams } from "@Pkg/cloudscript/cloudscript";
import { DAO, db } from "./dao";
export type ExportTarget = "local" | "tencentCloud";
// 导出与本地脚本关联记录
export interface Export {
id: number;
scriptId: number;
params: {
[key: string]: ExportParams;
};
// 导出目标
target: ExportTarget;
}
export class ExportDAO extends DAO<Export> {
public tableName = "export";
constructor() {
super();
this.table = db.table(this.tableName);
}
findByScriptID(scriptID: number) {
return this.table.where({ scriptId: scriptID }).first();
}
}

32
src/app/repo/logger.ts Normal file
View File

@ -0,0 +1,32 @@
import { LogLabel, LogLevel } from "../logger/core";
import { DAO, db } from "./dao";
export interface Logger {
id: number;
level: LogLevel;
message: string;
label: LogLabel;
createtime: number;
}
export class LoggerDAO extends DAO<Logger> {
public tableName = "logger";
constructor() {
super();
this.table = db.table(this.tableName);
}
async queryLogs(startTime: number, endTime: number) {
const ret = await this.table
.where("createtime")
.between(startTime, endTime)
.toArray();
return ret.sort((a, b) => b.createtime - a.createtime);
}
deleteBefore(time: number) {
return this.table.where("createtime").below(time).delete();
}
}

View File

@ -0,0 +1,20 @@
import { DAO, db } from "./dao";
export interface Permission {
id: number;
scriptId: number;
permission: string;
permissionValue: string;
allow: boolean;
createtime: number;
updatetime: number;
}
export class PermissionDAO extends DAO<Permission> {
public tableName = "permission";
constructor() {
super();
this.table = db.table(this.tableName);
}
}

32
src/app/repo/resource.ts Normal file
View File

@ -0,0 +1,32 @@
import { DAO, db } from "./dao";
export type ResourceType = "require" | "require-css" | "resource";
export interface Resource {
id: number;
url: string;
content: string;
base64: string;
hash: ResourceHash;
type: ResourceType;
contentType: string;
createtime: number;
updatetime?: number;
}
export interface ResourceHash {
md5: string;
sha1: string;
sha256: string;
sha384: string;
sha512: string;
}
export class ResourceDAO extends DAO<Resource> {
public tableName = "resource";
constructor() {
super();
this.table = db.table(this.tableName);
}
}

View File

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

121
src/app/repo/scripts.ts Normal file
View File

@ -0,0 +1,121 @@
import { DAO, db } from "./dao";
import { Resource } from "./resource";
import { Value } from "./value";
// 脚本模型
export type SCRIPT_TYPE = 1 | 2 | 3;
export const SCRIPT_TYPE_NORMAL: SCRIPT_TYPE = 1;
export const SCRIPT_TYPE_CRONTAB: SCRIPT_TYPE = 2;
export const SCRIPT_TYPE_BACKGROUND: SCRIPT_TYPE = 3;
export type SCRIPT_STATUS = 1 | 2 | 3 | 4;
export const SCRIPT_STATUS_ENABLE: SCRIPT_STATUS = 1;
export const SCRIPT_STATUS_DISABLE: SCRIPT_STATUS = 2;
// 弃用
export const SCRIPT_STATUS_ERROR: SCRIPT_STATUS = 3;
export const SCRIPT_STATUS_DELETE: SCRIPT_STATUS = 4;
export type SCRIPT_RUN_STATUS = "running" | "complete" | "error" | "retry";
export const SCRIPT_RUN_STATUS_RUNNING: SCRIPT_RUN_STATUS = "running";
export const SCRIPT_RUN_STATUS_COMPLETE: SCRIPT_RUN_STATUS = "complete";
export const SCRIPT_RUN_STATUS_ERROR: SCRIPT_RUN_STATUS = "error";
// 弃用
export const SCRIPT_RUN_STATUS_RETRY: SCRIPT_RUN_STATUS = "retry";
export type Metadata = { [key: string]: string[] };
export type ConfigType =
| "text"
| "checkbox"
| "select"
| "mult-select"
| "number"
| "textarea"
| "time";
export interface Config {
[key: string]: any;
title: string;
description: string;
default?: any;
type?: ConfigType;
bind?: string;
values?: any[];
password?: boolean;
// 文本类型时是字符串长度,数字类型时是最大值
max?: number;
min?: number;
rows?: number; // textarea行数
}
export type UserConfig = { [key: string]: { [key: string]: Config } };
export interface Script {
id: number; // 脚本id
uuid: string; // 脚本uuid,通过脚本uuid识别唯一脚本
name: string; // 脚本名称
code: string; // 脚本执行代码
namespace: string; // 脚本命名空间
author?: string; // 脚本作者
originDomain?: string; // 脚本来源域名
origin?: string; // 脚本来源
checkUpdateUrl?: string; // 检查更新URL
downloadUrl?: string; // 脚本下载URL
metadata: Metadata; // 脚本的元数据
selfMetadata?: Metadata; // 自定义脚本元数据
subscribeUrl?: string; // 如果是通过订阅脚本安装的话,会有订阅地址
config?: UserConfig; // 通过UserConfig定义的用户配置
type: SCRIPT_TYPE; // 脚本类型 1:普通脚本 2:定时脚本 3:后台脚本
status: SCRIPT_STATUS; // 脚本状态 1:启用 2:禁用 3:错误 4:初始化
sort: number; // 脚本顺序位置
runStatus: SCRIPT_RUN_STATUS; // 脚本运行状态,后台脚本才会有此状态 running:运行中 complete:完成 error:错误 retry:重试
error?: { error: string } | string; // 运行错误信息
createtime: number; // 脚本创建时间戳
updatetime?: number; // 脚本更新时间戳
checktime: number; // 脚本检查更新时间戳
lastruntime?: number; // 脚本最后一次运行时间戳
nextruntime?: number; // 脚本下一次运行时间戳
}
// 脚本运行时的资源,包含已经编译好的脚本与脚本需要的资源
export interface ScriptRunResouce extends Script {
grantMap: { [key: string]: string };
value: { [key: string]: Value };
flag: string;
resource: { [key: string]: Resource };
sourceCode: string;
}
export class ScriptDAO extends DAO<Script> {
public tableName = "scripts";
constructor() {
super();
this.table = db.table(this.tableName);
}
public findByName(name: string) {
return this.findOne({ name });
}
public findByNameAndNamespace(name: string, namespace?: string) {
if (namespace) {
return this.findOne({ name, namespace });
}
return this.findOne({ name });
}
public findByUUID(uuid: string) {
return this.findOne({ uuid });
}
public findByUUIDAndSubscribeUrl(uuid: string, suburl: string) {
return this.findOne({ subscribeUrl: suburl, uuid });
}
public findByOriginAndSubscribeUrl(origin: string, suburl: string) {
return this.findOne({ subscribeUrl: suburl, origin });
}
}

39
src/app/repo/subscribe.ts Normal file
View File

@ -0,0 +1,39 @@
import { DAO, db } from "./dao";
export type Metadata = { [key: string]: string[] };
export type SUBSCRIBE_STATUS = 1 | 2 | 3 | 4;
export const SUBSCRIBE_STATUS_ENABLE: SUBSCRIBE_STATUS = 1;
export const SUBSCRIBE_STATUS_DISABLE: SUBSCRIBE_STATUS = 2;
export interface SubscribeScript {
uuid: string;
url: string;
}
export interface Subscribe {
id: number;
url: string;
name: string;
code: string;
author: string;
scripts: { [key: string]: SubscribeScript };
metadata: Metadata;
status: SUBSCRIBE_STATUS;
createtime: number;
updatetime?: number;
checktime: number;
}
export class SubscribeDAO extends DAO<Subscribe> {
public tableName = "subscribe";
constructor() {
super();
this.table = db.table(this.tableName);
}
public findByUrl(url: string) {
return this.findOne({ url });
}
}

48
src/app/repo/sync.ts Normal file
View File

@ -0,0 +1,48 @@
/* eslint-disable camelcase */
export type SyncType = "script" | "subscribe";
export type SyncAction = "update" | "delete";
export interface SyncScript {
name: string;
uuid: string;
code: string;
meta_json: string;
self_meta: string;
origin: string;
sort: number;
subscribe_url?: string;
type: number;
createtime: number;
updatetime?: number;
}
export interface SycnSubscribe {
name: string;
url: string;
code: string;
meta_json: string;
scripts: string;
createtime: number;
updatetime?: number;
}
export interface SyncData {
action: SyncAction;
actiontime: number;
uuid?: string;
url?: string;
msg?: string;
script?: SyncScript;
subscribe?: SycnSubscribe;
}
export interface Sync {
id: number;
key: string;
user: number;
device: number;
type: SyncType;
data: SyncData;
createtime: number;
}

25
src/app/repo/value.ts Normal file
View File

@ -0,0 +1,25 @@
import Dexie from "dexie";
import { DAO, db } from "./dao";
export interface Value {
id: number;
scriptId: number;
storageName?: string;
key: string;
value: any;
createtime: number;
updatetime: number;
}
export class ValueDAO extends DAO<Value> {
public tableName = "value";
constructor(table?: Dexie.Table<Value, number>) {
super();
if (table) {
this.table = table;
} else {
this.table = db.table(this.tableName);
}
}
}

View File

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

View File

@ -6,6 +6,6 @@
"message": "脚本猫"
},
"scriptcat_description": {
"message": "万物皆可脚本化,让你的浏览器可以做更多的事情!"
"message": "万物皆可脚本化让你的浏览器可以做更多的事情"
}
}

View File

@ -18,7 +18,13 @@
},
"default_locale": "zh_CN",
"permissions": [
"offscreen"
"offscreen",
"scripting",
"webRequest",
"declarativeNetRequest"
],
"host_permissions": [
"*://*/*"
],
"sandbox": {
"pages": [

View File

@ -1,10 +1,22 @@
import { Connect, Server } from "@Packages/message";
import { extConnect } from "@Packages/message/extension";
import { ExtServer } from "@Packages/message/extension";
import { WindowServer } from "@Packages/message/window";
import migrate from "./app/migrate";
import LoggerCore from "./app/logger/core";
import DBWriter from "./app/logger/db_writer";
import { LoggerDAO } from "./app/repo/logger";
function main() {
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
debug: process.env.NODE_ENV === "development",
writer: new DBWriter(new LoggerDAO()),
labels: { env: "offscreen" },
});
loggerCore.logger().debug("offscreen start");
// 与sandbox建立连接
const extClient = new Connect(extConnect());
const server = new Server(new WindowServer(window));
server.on("connection", (con) => {
const wrapCon = new Connect(con);
@ -12,6 +24,15 @@ function main() {
console.log(data);
});
});
// 监听扩展消息
const extServer = new Server(new ExtServer());
extServer.on("connection", (con) => {
const wrapCon = new Connect(con);
wrapCon.on("recv", (data, resp) => {
console.log(data);
resp("service_wwww");
});
});
}
main();

343
src/pkg/utils/script.ts Normal file
View File

@ -0,0 +1,343 @@
import { v4 as uuidv4 } from "uuid";
import {
Metadata,
Script,
SCRIPT_RUN_STATUS_COMPLETE,
SCRIPT_STATUS_DISABLE,
SCRIPT_STATUS_ENABLE,
SCRIPT_TYPE_BACKGROUND,
SCRIPT_TYPE_CRONTAB,
SCRIPT_TYPE_NORMAL,
ScriptDAO,
UserConfig,
} from "@App/app/repo/scripts";
import { InstallSource } from "@App/app/service/manager";
import YAML from "yaml";
import { Subscribe, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe";
import { nextTime } from "./utils";
export function getMetadataStr(code: string): string | null {
const start = code.indexOf("==UserScript==");
const end = code.indexOf("==/UserScript==");
if (start === -1 || end === -1) {
return null;
}
return `// ${code.substring(start, end + 15)}`;
}
export function getUserConfigStr(code: string): string | null {
const start = code.indexOf("==UserConfig==");
const end = code.indexOf("==/UserConfig==");
if (start === -1 || end === -1) {
return null;
}
return `/* ${code.substring(start, end + 15)} */`;
}
export function parseMetadata(code: string): Metadata | null {
let issub = false;
let regex = /\/\/\s*==UserScript==([\s\S]+?)\/\/\s*==\/UserScript==/m;
let header = regex.exec(code);
if (!header) {
regex = /\/\/\s*==UserSubscribe==([\s\S]+?)\/\/\s*==\/UserSubscribe==/m;
header = regex.exec(code);
if (!header) {
return null;
}
issub = true;
}
regex = /\/\/\s*@([\S]+)((.+?)$|$)/gm;
const ret: Metadata = {};
let meta: RegExpExecArray | null = regex.exec(header[1]);
while (meta !== null) {
const [key, val] = [meta[1].toLowerCase().trim(), meta[2].trim()];
let values = ret[key];
if (values == null) {
values = [];
}
values.push(val);
ret[key] = values;
meta = regex.exec(header[1]);
}
if (ret.name === undefined) {
return null;
}
if (Object.keys(ret).length < 3) {
return null;
}
if (!ret.namespace) {
ret.namespace = [""];
}
if (issub) {
ret.usersubscribe = [];
}
return ret;
}
export function parseUserConfig(code: string): UserConfig | undefined {
const regex = /\/\*\s*==UserConfig==([\s\S]+?)\s*==\/UserConfig==\s*\*\//m;
const config = regex.exec(code);
if (!config) {
return undefined;
}
const configs = config[1].trim().split(/[-]{3,}/);
const ret: UserConfig = {};
configs.forEach((val) => {
const obj: UserConfig = YAML.parse(val);
Object.keys(obj).forEach((key) => {
ret[key] = obj[key];
});
});
return ret;
}
export type ScriptInfo = {
url: string;
code: string;
uuid: string;
isSubscribe: boolean;
isUpdate: boolean;
metadata: Metadata;
source: InstallSource;
};
export async function fetchScriptInfo(
url: string,
source: InstallSource,
isUpdate: boolean,
uuid: string
): Promise<ScriptInfo> {
const resp = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
},
});
if (resp.status !== 200) {
throw new Error("fetch script info failed");
}
if (resp.headers.get("content-type")?.indexOf("text/html") !== -1) {
throw new Error("url is html");
}
const body = await resp.text();
const parse = parseMetadata(body);
if (!parse) {
throw new Error("parse script info failed");
}
const ret: ScriptInfo = {
url,
code: body,
uuid,
isSubscribe: false,
isUpdate,
metadata: parse,
source,
};
if (parse.usersubscribe) {
ret.isSubscribe = true;
}
return ret;
}
export function copyScript(script: Script, old: Script): Script {
const ret = script;
ret.id = old.id;
ret.uuid = old.uuid;
ret.createtime = old.createtime;
ret.lastruntime = old.lastruntime;
// ret.delayruntime = old.delayruntime;
ret.error = old.error;
ret.sort = old.sort;
if (!ret.selfMetadata) {
ret.selfMetadata = old.selfMetadata || {};
}
ret.subscribeUrl = old.subscribeUrl;
ret.status = old.status;
return ret;
}
export function copySubscribe(sub: Subscribe, old: Subscribe): Subscribe {
const ret = sub;
ret.id = old.id;
ret.scripts = old.scripts;
ret.createtime = old.createtime;
ret.status = old.status;
return ret;
}
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(<string>reader.result);
reader.readAsDataURL(blob);
});
}
export function blobToText(blob: Blob): Promise<string | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(<string | null>reader.result);
reader.readAsText(blob);
});
}
export function base64ToBlob(dataURI: string) {
const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0];
const byteString = atob(dataURI.split(",")[1]);
const arrayBuffer = new ArrayBuffer(byteString.length);
const intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i += 1) {
intArray[i] = byteString.charCodeAt(i);
}
return new Blob([intArray], { type: mimeString });
}
export function strToBase64(str: string): string {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1: string) => {
return String.fromCharCode(parseInt(`0x${p1}`, 16));
})
);
}
// 通过代码解析出脚本信息
export function prepareScriptByCode(
code: string,
url: string,
uuid?: string,
override?: boolean
): Promise<{ script: Script; oldScript?: Script }> {
const dao = new ScriptDAO();
return new Promise((resolve, reject) => {
const metadata = parseMetadata(code);
if (metadata == null) {
throw new Error("MetaData信息错误");
}
if (metadata.name === undefined) {
throw new Error("脚本名不能为空");
}
if (metadata.version === undefined) {
throw new Error("脚本@version版本不能为空");
}
if (metadata.namespace === undefined) {
throw new Error("脚本@namespace命名空间不能为空");
}
let type = SCRIPT_TYPE_NORMAL;
if (metadata.crontab !== undefined) {
type = SCRIPT_TYPE_CRONTAB;
try {
nextTime(metadata.crontab[0]);
} catch {
throw new Error(`错误的定时表达式,请检查: ${metadata.crontab[0]}`);
}
} else if (metadata.background !== undefined) {
type = SCRIPT_TYPE_BACKGROUND;
}
let urlSplit: string[];
let domain = "";
let checkUpdateUrl = "";
let downloadUrl = url;
if (metadata.updateurl && metadata.downloadurl) {
[checkUpdateUrl] = metadata.updateurl;
[downloadUrl] = metadata.downloadurl;
} else {
checkUpdateUrl = url.replace("user.js", "meta.js");
}
if (url.indexOf("/") !== -1) {
urlSplit = url.split("/");
if (urlSplit[2]) {
[, domain] = urlSplit;
}
}
let newUUID = "";
if (uuid) {
newUUID = uuid;
} else {
newUUID = uuidv4();
}
let script: Script = {
id: 0,
uuid: newUUID,
name: metadata.name[0],
code,
author: metadata.author && metadata.author[0],
namespace: metadata.namespace && metadata.namespace[0],
originDomain: domain,
origin: url,
checkUpdateUrl,
downloadUrl,
config: parseUserConfig(code),
metadata,
selfMetadata: {},
sort: -1,
type,
status: SCRIPT_STATUS_DISABLE,
runStatus: SCRIPT_RUN_STATUS_COMPLETE,
createtime: Date.now(),
updatetime: Date.now(),
checktime: Date.now(),
};
const handler = async () => {
let old: Script | undefined;
if (uuid) {
old = await dao.findByUUID(uuid);
if (!old && override) {
old = await dao.findByNameAndNamespace(script.name, script.namespace);
}
} else {
old = await dao.findByNameAndNamespace(script.name, script.namespace);
}
if (old) {
if (
(old.type === SCRIPT_TYPE_NORMAL && script.type !== SCRIPT_TYPE_NORMAL) ||
(script.type === SCRIPT_TYPE_NORMAL && old.type !== SCRIPT_TYPE_NORMAL)
) {
reject(new Error("脚本类型不匹配,普通脚本与后台脚本不能互相转变"));
return;
}
script = copyScript(script, old);
} else {
// 前台脚本默认开启
if (script.type === SCRIPT_TYPE_NORMAL) {
script.status = SCRIPT_STATUS_ENABLE;
}
script.checktime = new Date().getTime();
}
resolve({ script, oldScript: old });
};
handler();
});
}
export async function prepareSubscribeByCode(
code: string,
url: string
): Promise<{ subscribe: Subscribe; oldSubscribe?: Subscribe }> {
const dao = new SubscribeDAO();
const metadata = parseMetadata(code);
if (metadata == null) {
throw new Error("MetaData信息错误");
}
if (metadata.name === undefined) {
throw new Error("订阅名不能为空");
}
let subscribe: Subscribe = {
id: 0,
url,
name: metadata.name[0],
code,
author: metadata.author && metadata.author[0],
scripts: {},
metadata,
status: SUBSCRIBE_STATUS_ENABLE,
createtime: Date.now(),
updatetime: Date.now(),
checktime: Date.now(),
};
const old = await dao.findByUrl(url);
if (old) {
subscribe = copySubscribe(subscribe, old);
}
return Promise.resolve({ subscribe, oldSubscribe: old });
}

View File

@ -0,0 +1,96 @@
import { formatTime, nextTime, ltever, checkSilenceUpdate } from "./utils";
import dayjs from "dayjs";
describe("nextTime", () => {
test("每分钟表达式", () => {
expect(nextTime("* * * * *")).toEqual(
dayjs(new Date()).add(1, "minute").format("YYYY-MM-DD HH:mm:00")
);
});
test("每分钟一次表达式", () => {
expect(nextTime("once * * * *")).toEqual(
dayjs(new Date())
.add(1, "minute")
.format("YYYY-MM-DD HH:mm 每分钟运行一次")
);
});
test("每小时一次表达式", () => {
expect(nextTime("* once * * *")).toEqual(
dayjs(new Date()).add(1, "hour").format("YYYY-MM-DD HH 每小时运行一次")
);
});
test("每天一次表达式", () => {
expect(nextTime("* * once * *")).toEqual(
dayjs(new Date()).add(1, "day").format("YYYY-MM-DD 每天运行一次")
);
});
test("每月一次表达式", () => {
expect(nextTime("* * * once *")).toEqual(
dayjs(new Date()).add(1, "month").format("YYYY-MM 每月运行一次")
);
});
test("每星期一次表达式", () => {
expect(nextTime("* * * * once")).toEqual(
dayjs(new Date()).add(1, "week").format("YYYY-MM-DD 每星期运行一次")
);
});
});
describe("ltever", () => {
it("semver", () => {
expect(ltever("1.0.0", "1.0.1")).toBe(true);
expect(ltever("1.0.0", "1.0.0")).toBe(true);
expect(ltever("1.0.1", "1.0.0")).toBe(false);
});
it("any", () => {
expect(ltever("1.2.3.4", "1.2.3.4")).toBe(true);
expect(ltever("1.2.3.4", "1.2.3.5")).toBe(true);
expect(ltever("1.2.3.4", "1.2.3.3")).toBe(false);
});
});
describe("checkSilenceUpdate", () => {
it("true", () => {
expect(
checkSilenceUpdate(
{
connect: ["www.baidu.com"],
},
{
connect: ["www.baidu.com"],
}
)
).toBe(true);
expect(
checkSilenceUpdate(
{
connect: ["www.baidu.com", "scriptcat.org"],
},
{
connect: ["scriptcat.org"],
}
)
).toBe(true);
});
it("false", () => {
expect(
checkSilenceUpdate(
{
connect: ["www.baidu.com"],
},
{
connect: ["www.google.com"],
}
)
).toBe(false);
expect(
checkSilenceUpdate(
{
connect: ["www.baidu.com"],
},
{
connect: ["www.baidu.com", "scriptcat.org"],
}
)
).toBe(false);
});
});

184
src/pkg/utils/utils.ts Normal file
View File

@ -0,0 +1,184 @@
import { CronTime } from "cron";
import dayjs from "dayjs";
import semver from "semver";
export function nextTime(crontab: string): string {
let oncePos = 0;
if (crontab.indexOf("once") !== -1) {
const vals = crontab.split(" ");
vals.forEach((val, index) => {
if (val === "once") {
oncePos = index;
}
});
if (vals.length === 5) {
oncePos += 1;
}
}
let cron: CronTime;
try {
cron = new CronTime(crontab.replace(/once/g, "*"));
} catch {
throw new Error("错误的定时表达式");
}
if (oncePos) {
switch (oncePos) {
case 1: // 每分钟
return cron.sendAt().toFormat("yyyy-MM-dd HH:mm 每分钟运行一次");
case 2: // 每小时
return cron.sendAt().plus({ hour: 1 }).toFormat("yyyy-MM-dd HH 每小时运行一次");
case 3: // 每天
return cron.sendAt().plus({ day: 1 }).toFormat("yyyy-MM-dd 每天运行一次");
case 4: // 每月
return cron.sendAt().plus({ month: 1 }).toFormat("yyyy-MM 每月运行一次");
case 5: // 每星期
return cron.sendAt().plus({ week: 1 }).toFormat("yyyy-MM-dd 每星期运行一次");
}
throw new Error("错误表达式");
}
return cron.sendAt().toFormat("yyyy-MM-dd HH:mm:ss");
}
export function formatTime(time: Date) {
return dayjs(time).format("YYYY-MM-DD HH:mm:ss");
}
export function formatUnixTime(time: number) {
return dayjs.unix(time).format("YYYY-MM-DD HH:mm:ss");
}
export function semTime(time: Date) {
return dayjs().to(dayjs(time));
}
export function randomString(e: number) {
e = e || 32;
const t = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz";
const a = t.length;
let n = "";
for (let i = 0; i < e; i += 1) {
n += t.charAt(Math.floor(Math.random() * a));
}
return n;
}
export function dealSymbol(source: string): string {
source = source.replace(/("|\\)/g, "\\$1");
source = source.replace(/(\r\n|\n)/g, "\\n");
return source;
}
export function dealScript(source: string): string {
return dealSymbol(source);
}
export function isFirefox() {
if (navigator.userAgent.indexOf("Firefox") >= 0) {
return true;
}
return false;
}
export function InfoNotification(title: string, msg: string) {
chrome.notifications.create({
type: "basic",
title,
message: msg,
iconUrl: chrome.runtime.getURL("assets/logo.png"),
});
}
export function valueType(val: unknown) {
switch (typeof val) {
case "string":
case "number":
case "boolean":
case "object":
return typeof val;
default:
return "unknown";
}
}
export function toStorageValueStr(val: unknown): string {
switch (typeof val) {
case "string":
return `s${val}`;
case "number":
return `n${val.toString()}`;
case "boolean":
return `b${val ? "true" : "false"}`;
default:
try {
return `o${JSON.stringify(val)}`;
} catch {
return "";
}
}
}
export function parseStorageValue(str: string): unknown {
if (str === "") {
return undefined;
}
const t = str[0];
const s = str.substring(1);
switch (t) {
case "b":
return s === "true";
case "n":
return parseFloat(s);
case "o":
try {
return JSON.parse(s);
} catch {
return str;
}
case "s":
return s;
default:
return str;
}
}
// 对比版本大小
export function ltever(newVersion: string, oldVersion: string) {
// 先验证符不符合语义化版本规范
try {
return semver.lte(newVersion, oldVersion);
} catch (e) {
console.error(e);
}
const newVer = newVersion.split(".");
const oldVer = oldVersion.split(".");
for (let i = 0; i < newVer.length; i += 1) {
if (Number(newVer[i]) > Number(oldVer[i])) {
return false;
}
if (Number(newVer[i]) < Number(oldVer[i])) {
return true;
}
}
return true;
}
// 在当前页后打开一个新页面
export function openInCurrentTab(url: string) {
chrome.tabs.query(
{
active: true,
},
(tabs) => {
if (tabs.length) {
chrome.tabs.create({
url,
index: tabs[0].index + 1,
});
} else {
chrome.tabs.create({
url,
});
}
}
);
}

View File

@ -1,34 +1,65 @@
import { ExtServer } from "@Packages/message/extension";
import { Connect, Server } from "@Packages/message";
import { extConnect } from "@Packages/message/extension";
import Manager from "./app/service/manager";
import { Connect } from "@Packages/message";
import migrate from "./app/migrate";
import LoggerCore from "./app/logger/core";
import DBWriter from "./app/logger/db_writer";
import { LoggerDAO } from "./app/repo/logger";
const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html";
let creating: Promise<void> | null;
async function hasDocument() {
const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);
const existingContexts = await chrome.runtime.getContexts({
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
documentUrls: [offscreenUrl],
});
return existingContexts.length > 0;
}
async function setupOffscreenDocument() {
// 创建运行后台脚本的沙盒环境
await chrome.offscreen.createDocument({
url: "src/offscreen.html",
reasons: [chrome.offscreen.Reason.CLIPBOARD],
justification: "offscreen page",
});
//if we do not have a document, we are already setup and can skip
if (!(await hasDocument())) {
// create offscreen document
if (creating) {
await creating;
} else {
creating = chrome.offscreen.createDocument({
url: OFFSCREEN_DOCUMENT_PATH,
reasons: [
chrome.offscreen.Reason.CLIPBOARD,
chrome.offscreen.Reason.DOM_SCRAPING,
chrome.offscreen.Reason.LOCAL_STORAGE,
],
justification: "offscreen page",
});
// Send message to offscreen document
chrome.runtime.sendMessage({
type: "init",
target: "offscreen",
data: { init: "1" },
});
await creating;
creating = null;
}
}
}
async function main() {
// 监听消息
const server = new Server(new ExtServer());
server.on("connection", (con) => {
const wrapCon = new Connect(con);
wrapCon.on("recv", (data,resp) => {
console.log(data);
resp("service_wwww");
});
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
debug: process.env.NODE_ENV === "development",
writer: new DBWriter(new LoggerDAO()),
labels: { env: "background" },
});
loggerCore.logger().debug("background start");
// 初始化沙盒环境
await setupOffscreenDocument();
// 初始化连接
const extClient = new Connect(extConnect());
// 初始化管理器
const manager = new Manager(extClient);
manager.initManager();
}
main();