云同步功能

This commit is contained in:
王一之 2025-04-21 18:02:35 +08:00
parent 185ba6e5cc
commit 1de1ba6373
9 changed files with 376 additions and 18 deletions

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { MessageQueue } from "@Packages/message/message_queue";
import { Script, SCRIPT_RUN_STATUS } from "../repo/scripts";
import { InstallSource } from "./service_worker";
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);
}

View File

@ -347,7 +347,7 @@ export default class GMApi {
const url = URL.createObjectURL(blob);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 30*1000);
}, 30 * 1000);
return { action: "onload", data: url };
} catch (e: any) {
return { action: "error", data: { code: 5, error: e.message } };

View File

@ -39,7 +39,15 @@ export default class ServiceWorkerManager {
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"), value, resource);
const synchronize = new SynchronizeService(
this.sender,
this.api.group("synchronize"),
script,
value,
resource,
this.mq,
systemConfig
);
synchronize.init();
// 定时器处理
@ -48,6 +56,13 @@ export default class ServiceWorkerManager {
case "checkScriptUpdate":
script.checkScriptUpdate();
break;
case "cloudSync":
// 进行一次云同步
systemConfig.getCloudSync().then((config) => {
synchronize.buildFileSystem(config).then((fs) => {
synchronize.syncOnce(fs);
});
});
}
});
@ -56,10 +71,14 @@ export default class ServiceWorkerManager {
console.log("systemConfigChange", msg);
switch (msg.key) {
case "cloud_sync": {
synchronize.startCloudSync(msg.value);
synchronize.cloudSyncConfigChange(msg.value);
break;
}
}
});
// 启动一次云同步
systemConfig.getCloudSync().then((config) => {
synchronize.cloudSyncConfigChange(config);
});
}
}

View File

@ -179,7 +179,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) => {

View File

@ -4,7 +4,7 @@ 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 from "@Packages/filesystem/filesystem";
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";
@ -13,8 +13,31 @@ import { ResourceService } from "./resource";
import dayjs from "dayjs";
import { createObjectURL } from "../offscreen/client";
import FileSystemFactory, { FileSystemType } from "@Packages/filesystem/factory";
import { systemConfig } from "@App/pages/store/global";
import { CloudSyncConfig } from "@App/pkg/config/config";
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;
@ -22,11 +45,16 @@ export class SynchronizeService {
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 resource: ResourceService,
private mq: MessageQueue,
private systemConfig: SystemConfig
) {
this.logger = LoggerCore.logger().with({ service: "synchronize" });
}
@ -226,12 +254,289 @@ export class SynchronizeService {
return Promise.resolve();
}
cloudSync() {}
// 开始一次云同步
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;
}
startCloudSync(value: CloudSyncConfig) {
// 同步一次
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.cloudSync();
// 开启云同步同步
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);
});
}
}
@ -239,5 +544,8 @@ export class SynchronizeService {
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

@ -138,7 +138,9 @@ function Tools() {
<Button
key="list"
type="primary"
loading={loading.cloud}
onClick={async () => {
setLoading((prev) => ({ ...prev, cloud: true }));
let fs = await FileSystemFactory.create(fileSystemType, fileSystemParams);
try {
fs = await fs.openDir("ScriptCat");
@ -154,6 +156,7 @@ function Tools() {
} catch (e) {
Message.error(`${t("get_backup_files_failed")}: ${e}`);
}
setLoading((prev) => ({ ...prev, cloud: false }));
}}
>
{t("backup_list")}

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