diff --git a/packages/message/README.md b/packages/message/README.md new file mode 100644 index 0000000..71fe041 --- /dev/null +++ b/packages/message/README.md @@ -0,0 +1,10 @@ +# 消息 + +对扩展内消息交互的抽象 + +主要会有以下几种类型的消息: + +- 从脚本发起的GM请求,需要层层传递到service_worker/offscreen进行处理,有的GM只需要进行一次调用获取一次结果,有的需要进行 + 多次调用获取多次结果,使用connect的方式实现 +- 从service_woker/offscreen发起的请求,类似消息队列,其它页面进行监听,触发后广播给所有页面,使用connect方式实现 +- 从扩展页面发起的请求,需要传递到service_worker/offscreen进行处理,如果只是单次调用,获取一次结果,使用message方式实现 diff --git a/packages/message/client.ts b/packages/message/client.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/message/extension.ts b/packages/message/extension.ts deleted file mode 100644 index bd1cfe0..0000000 --- a/packages/message/extension.ts +++ /dev/null @@ -1,54 +0,0 @@ -import EventEmitter from "eventemitter3"; -import { IConnect, IServer } from "."; - -export class ExtServer implements IServer { - private EE: EventEmitter; - - constructor() { - this.EE = new EventEmitter(); - chrome.runtime.onConnect.addListener((port) => { - this.EE.emit("connect", new ExtConnect(port)); - }); - } - - onConnect(callback: (con: IConnect) => void) { - this.EE.on("connect", callback); - } -} - -export function extConnect() { - return new ExtConnect(chrome.runtime.connect()); -} - -export class ExtConnect implements IConnect { - private EE: EventEmitter; - private port: chrome.runtime.Port; - - constructor(port: chrome.runtime.Port) { - this.EE = new EventEmitter(); - this.port = port; - port.onMessage.addListener((message) => { - this.EE.emit("message", message); - }); - port.onDisconnect.addListener(() => { - this.EE.emit("disconnect"); - this.EE.removeAllListeners(); - }); - } - - postMessage(message: unknown) { - this.port.postMessage(message); - } - - onMessage(callback: (message: unknown) => void) { - this.EE.on("message", callback); - } - - onDisconnect(callback: () => void) { - this.EE.on("disconnect", callback); - } - - disconnect() { - this.port.disconnect(); - } -} diff --git a/packages/message/index.ts b/packages/message/index.ts deleted file mode 100644 index 8c60673..0000000 --- a/packages/message/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import EventEmitter from "eventemitter3"; -import { v4 as uuidv4 } from "uuid"; - -export interface IServer { - onConnect: (callback: (con: IConnect) => void) => void; -} - -export interface IConnect { - postMessage: (message: unknown) => void; - onMessage: (callback: (message: unknown) => void) => void; - onDisconnect: (callback: () => void) => void; - disconnect: () => void; -} - -// 消息通道, 通过连接封装消息通道 -export class Server { - private EE: EventEmitter; - - constructor(private connect: IServer) { - this.EE = new EventEmitter(); - this.connect.onConnect((con) => { - this.EE.emit("connection", con); - }); - } - - on(eventName: "connection", callback: (con: IConnect) => void): void; - on(eventName: string, callback: (con: IConnect) => void) { - this.EE.on(eventName, callback); - } -} - -export class Connect { - private EE: EventEmitter; - - constructor(private con: IConnect) { - this.EE = new EventEmitter(); - this.con.onMessage((message) => { - this.messageHandler(message); - }); - this.con.onDisconnect(() => { - this.EE.emit("disconnect"); - this.EE.removeAllListeners(); - }); - } - - private callbackFunc(msgId: string): (...data: unknown[]) => void { - return (...data: unknown[]) => { - this.con.postMessage({ eventName: "callback", data, messageId: msgId }); - }; - } - - private messageHandler(data: unknown) { - const subData = data as { eventName: string; data: unknown[]; messageId: string; conType: string; id: string }; - if (subData.eventName === "callback") { - this.EE.emit(subData.eventName + subData.messageId, ...subData.data); - return; - } - subData.data.push(this.callbackFunc(subData.messageId)); - this.EE.emit(subData.eventName, ...subData.data); - } - - on(eventName: string, callback: (...args: any[]) => void) { - this.EE.on(eventName, callback); - } - - send(eventName: string, ...data: unknown[]) { - this.con.postMessage({ eventName, data }); - } - - emit(eventName: string, ...data: any[]) { - // 判断最后一个参数是否为函数 - const callback = data.pop(); - const messageId = uuidv4(); - if (typeof callback !== "function") { - data.push(callback); - } else { - this.EE.on("callback" + messageId, (...args) => { - callback(...args); - }); - } - const sendData = { eventName, data, messageId }; - this.con.postMessage(sendData); - } -} diff --git a/packages/message/message.test.ts b/packages/message/message.test.ts deleted file mode 100644 index 1280a27..0000000 --- a/packages/message/message.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it, vi } from "vitest"; -import { Server, Connect } from "."; -import { windowConnect, WindowServer } from "./window"; - -describe("server", () => { - it("hello", async () => { - const myFunc = vi.fn(); - const server = new Server(new WindowServer(global.window)); - server.on("connection", (con) => { - myFunc(); - con.onMessage((message) => { - myFunc(message); - }); - }); - const client = windowConnect(window, window); - client.postMessage("hello"); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(myFunc).toHaveBeenCalledTimes(2); - expect(myFunc).toHaveBeenCalledWith("hello"); - }); -}); - -describe("connect", async () => { - it("hello", async () => { - const server = new Server(new WindowServer(global.window)); - const myFunc = vi.fn(); - server.on("connection", (con) => { - myFunc(); - const wrapCon = new Connect(con); - wrapCon.on("hello", (message) => { - myFunc(message); - wrapCon.emit("world", "world"); - }); - }); - const client = new Connect(windowConnect(window, window)); - client.on("world", (message) => { - myFunc(message); - }); - client.emit("hello", "hello"); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(myFunc).toHaveBeenCalledTimes(3); - expect(myFunc).toHaveBeenCalledWith("hello"); - expect(myFunc).toHaveBeenCalledWith("world"); - }); - it("response", async () => { - const server = new Server(new WindowServer(global.window)); - const myFunc = vi.fn(); - server.on("connection", (con) => { - const wrapCon = new Connect(con); - wrapCon.on("ping", (message, response) => { - myFunc(message); - response("pong"); - }); - }); - const client = new Connect(windowConnect(window, window)); - client.emit("ping", "ping", (message: string) => { - myFunc(message); - }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(myFunc).toHaveBeenCalledTimes(2); - expect(myFunc).toHaveBeenCalledWith("ping"); - expect(myFunc).toHaveBeenCalledWith("pong"); - }); -}); diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts new file mode 100644 index 0000000..f233e7e --- /dev/null +++ b/packages/message/message_queue.ts @@ -0,0 +1,66 @@ +import { ApiFunction } from "./server"; + +export class Broker { + constructor() {} + + // 订阅 + subscribe(topic: string, handler: (message: any) => void) { + const con = chrome.runtime.connect({ name: topic }); + con.postMessage({ action: "subscribe", topic }); + con.onMessage.addListener((msg: { action: string; topic: string; message: any }) => { + if (msg.action === "message") { + handler(msg.message); + } + }); + } + + // 发布 + publish(topic: string, message: any) { + chrome.runtime.sendMessage({ action: "publish", topic, message }); + } +} + +// 消息队列 +export class MessageQueue { + topicConMap: Map = new Map(); + + handler(): ApiFunction { + return ({ action, topic, message }: { action: string; topic: string; message: any }, con) => { + if (!con) { + throw new Error("con is required"); + } + if (!topic) { + throw new Error("topic is required"); + } + switch (action) { + case "subscribe": + this.subscribe(topic, con as chrome.runtime.Port); + break; + case "publish": + this.publish(topic, message); + break; + default: + throw new Error("action not found"); + } + }; + } + + private subscribe(topic: string, con: chrome.runtime.Port) { + let list = this.topicConMap.get(topic); + if (!list) { + list = []; + this.topicConMap.set(topic, list); + } + list.push({ name: topic, con }); + con.onDisconnect.addListener(() => { + list = list!.filter((item) => item.con !== con); + }); + } + + publish(topic: string, message: any) { + const list = this.topicConMap.get(topic); + list?.forEach((item) => { + item.con.postMessage({ action: "message", topic, message }); + }); + } +} diff --git a/packages/message/server.ts b/packages/message/server.ts new file mode 100644 index 0000000..95f0d6c --- /dev/null +++ b/packages/message/server.ts @@ -0,0 +1,45 @@ +export type ApiFunction = (params: any, con: chrome.runtime.Port | chrome.runtime.MessageSender) => any; + +export class Server { + apiFunctionMap: Map = new Map(); + + constructor() { + chrome.runtime.onConnect.addListener((port) => { + port.onMessage.addListener((msg: { action: string }) => { + this.connectHandle(msg.action, msg, port); + }); + }); + + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + this.messageHandle(msg.action, msg, sender, sendResponse); + }); + } + + on(name: string, func: ApiFunction) { + this.apiFunctionMap.set(name, func); + } + + private connectHandle(msg: string, params: any, con: chrome.runtime.Port) { + const func = this.apiFunctionMap.get(msg); + if (func) { + func(params, con); + } + } + + private messageHandle( + msg: string, + params: any, + sender: chrome.runtime.MessageSender, + sendResponse: (response: any) => void + ) { + const func = this.apiFunctionMap.get(msg); + if (func) { + try { + const ret = func(params, sender); + sendResponse({ code: 0, data: ret }); + } catch (e: any) { + sendResponse({ code: -1, message: e.message }); + } + } + } +} diff --git a/packages/message/window.ts b/packages/message/window.ts deleted file mode 100644 index 5ef46c7..0000000 --- a/packages/message/window.ts +++ /dev/null @@ -1,61 +0,0 @@ -import EventEmitter from "eventemitter3"; -import { IConnect, IServer } from "."; -import { v4 as uuidv4 } from "uuid"; - -export class WindowServer implements IServer { - private EE: EventEmitter; - - constructor(win: Window) { - this.EE = new EventEmitter(); - win.addEventListener("message", (event) => { - if (event.data.type === "connect") { - this.EE.emit("connection", new WindowConnect(event.data.connectId, win, event.source as Window)); - } - }); - } - - onConnect(callback: (con: IConnect) => void) { - this.EE.on("connection", callback); - } -} - -export function windowConnect(source: Window, target: Window) { - const connectId = uuidv4(); - target.postMessage({ type: "connect", connectId }, "*"); - const con = new WindowConnect(connectId, source, target); - return con; -} - -export class WindowConnect implements IConnect { - private EE: EventEmitter; - - constructor( - private id: string, - private source: Window, - private target: Window - ) { - this.EE = new EventEmitter(); - this.source.addEventListener("message", (event) => { - if (event.data.eventName === "message" && event.data.id === id) { - this.EE.emit("message", event.data.data); - } - }); - } - - postMessage(data: unknown) { - this.target.postMessage({ eventName: "message", id: this.id, data }, "*"); - } - - onMessage(callback: (message: unknown) => void) { - this.EE.on("message", callback); - } - - onDisconnect(callback: () => void) { - this.EE.on("disconnect", callback); - } - - disconnect() { - this.EE.emit("disconnect"); - this.EE.removeAllListeners(); - } -} diff --git a/src/app/service/manager/index.ts b/src/app/service/manager/index.ts index 3e9248d..ee8b686 100644 --- a/src/app/service/manager/index.ts +++ b/src/app/service/manager/index.ts @@ -1,124 +1,11 @@ -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"; +import { Server } from "@Packages/message/server"; -export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode"; - -export default class Manager { - constructor(private connect: Connect) {} - - listenerScriptInstall() { - // 初始化脚本安装监听 - chrome.webRequest.onCompleted.addListener( - (req: chrome.webRequest.WebResponseCacheDetails) => { - // 处理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]; - // 读取脚本url内容, 进行安装 - LoggerCore.getInstance().logger().debug("install script", { url: targetUrl }); - this.openInstallPageByUrl(targetUrl).catch(() => { - // 如果打开失败, 则重定向到安装页 - chrome.scripting.executeScript({ - target: { tabId: req.tabId }, - func: function () { - history.back(); - }, - }); - // 并不再重定向当前url - chrome.declarativeNetRequest.updateDynamicRules( - { - removeRuleIds: [2], - addRules: [ - { - id: 2, - priority: 1, - action: { - type: chrome.declarativeNetRequest.RuleActionType.ALLOW, - }, - condition: { - regexFilter: targetUrl, - resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], - requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET], - }, - }, - ], - }, - () => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - } - } - ); - }); - }, - { - urls: [ - "https://docs.scriptcat.org/docs/script_installation", - "https://www.tampermonkey.net/script_installation.php", - ], - types: ["main_frame"], - } - ); - // 重定向到脚本安装页 - 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}`); - }); - } +// offscreen环境的管理器 +export class Manager { + private api: Server = new Server(); initManager() { - this.listenerScriptInstall(); + // 监听消息 + } } diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts new file mode 100644 index 0000000..8500d51 --- /dev/null +++ b/src/app/service/service_worker/index.ts @@ -0,0 +1,139 @@ +import { fetchScriptInfo } from "@App/pkg/utils/script"; +import { v4 as uuidv4 } from "uuid"; +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"; +import { Server } from "@Packages/message/server"; +import { MessageQueue } from "@Packages/message/message_queue"; + +export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode"; + +// service worker的管理器 +export default class ServiceWorkerManager { + constructor() {} + + listenerScriptInstall() { + // 初始化脚本安装监听 + chrome.webRequest.onCompleted.addListener( + (req: chrome.webRequest.WebResponseCacheDetails) => { + // 处理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]; + // 读取脚本url内容, 进行安装 + LoggerCore.getInstance().logger().debug("install script", { url: targetUrl }); + this.openInstallPageByUrl(targetUrl).catch(() => { + // 如果打开失败, 则重定向到安装页 + chrome.scripting.executeScript({ + target: { tabId: req.tabId }, + func: function () { + history.back(); + }, + }); + // 并不再重定向当前url + chrome.declarativeNetRequest.updateDynamicRules( + { + removeRuleIds: [2], + addRules: [ + { + id: 2, + priority: 1, + action: { + type: chrome.declarativeNetRequest.RuleActionType.ALLOW, + }, + condition: { + regexFilter: targetUrl, + resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], + requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET], + }, + }, + ], + }, + () => { + if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError); + } + } + ); + }); + }, + { + urls: [ + "https://docs.scriptcat.org/docs/script_installation", + "https://www.tampermonkey.net/script_installation.php", + ], + types: ["main_frame"], + } + ); + // 重定向到脚本安装页 + 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}`); + }); + } + + private api: Server = new Server(); + + private mq: MessageQueue = new MessageQueue(); + + // 获取安装信息 + getInstallInfo(params: { uuid: string }) { + const info = Cache.getInstance().get(CacheKey.scriptInfo(params.uuid)); + return info; + } + + initManager() { + // 监听消息 + this.api.on("getInstallInfo", this.getInstallInfo); + + this.listenerScriptInstall(); + } +} diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index 9099b6c..0360880 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -11,7 +11,7 @@ import { ScriptDAO, UserConfig, } from "@App/app/repo/scripts"; -import { InstallSource } from "@App/app/service/manager"; +import { InstallSource } from "@App/app/service/service_worker"; import YAML from "yaml"; import { Subscribe, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe"; import { nextTime } from "./utils"; diff --git a/src/service_worker.ts b/src/service_worker.ts index 467066f..a8a3d3f 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -1,6 +1,4 @@ -import { extConnect } from "@Packages/message/extension"; -import Manager from "./app/service/manager"; -import { Connect } from "@Packages/message"; +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"; @@ -55,10 +53,8 @@ async function main() { loggerCore.logger().debug("background start"); // 初始化沙盒环境 await setupOffscreenDocument(); - // 初始化连接 - const extClient = new Connect(extConnect()); // 初始化管理器 - const manager = new Manager(extClient); + const manager = new ServiceWorkerManager(); manager.initManager(); }