diff --git a/packages/message/client.ts b/packages/message/client.ts index 92e83a3..1c5517d 100644 --- a/packages/message/client.ts +++ b/packages/message/client.ts @@ -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 { 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; + } } } diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index de29e66..a0060c5 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -123,9 +123,16 @@ export class ExtensionContentMessageSend extends ExtensionMessageSend { sendMessage(data: any): Promise { 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); + }); + } }); } diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index d0eab70..d11e7d2 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -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); } diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 9d8b973..7cd4953 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -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 } }; diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index c946b63..70e5d41 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -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); + }); } } diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 7d74d89..c534804 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -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) => { diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 8fba6d8..2127c1f 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -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(); + scriptList.forEach((script) => { + scriptMap.set(script.uuid, script); + }); + // 对比脚本列表和文件列表,进行同步 + const result: Promise[] = []; + 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({ + 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({ + 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)); } } diff --git a/src/pages/options/routes/Tools.tsx b/src/pages/options/routes/Tools.tsx index f562957..e239da2 100644 --- a/src/pages/options/routes/Tools.tsx +++ b/src/pages/options/routes/Tools.tsx @@ -138,7 +138,9 @@ function Tools() {