diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index b3f9474cf..1abfa9803 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -18,47 +18,93 @@ // @run-at document-start // ==/UserScript== -(async function () { - "use strict"; - - console.log("%c=== Content环境 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - - let testResults = { - passed: 0, - failed: 0, - total: 0, - }; - - // 测试辅助函数(支持同步和异步) - async function test(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } +// 测试辅助函数(支持同步和异步) +async function test(name, fn) { + testResults.total++; + try { + await fn(); + testResults.passed++; + console.log(`%c✓ ${name}`, "color: green;"); + return true; + } catch (error) { + testResults.failed++; + console.error(`%c✗ ${name}`, "color: red;", error); + return false; } - - // assert(expected, actual, message) - 比较两个值是否相等 - function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } +} + +function testSync(name, fn) { + testResults.total++; + try { + fn(); + testResults.passed++; + console.log(`%c✓ ${name}`, "color: green;"); + return true; + } catch (error) { + testResults.failed++; + console.error(`%c✗ ${name}`, "color: red;", error); + return false; } +} + +// assert(expected, actual, message) - 比较两个值是否相等 +function assert(expected, actual, message) { + if (expected !== actual) { + const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; + const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; + throw new Error(error); + } +} - // assertTrue(condition, message) - 断言条件为真 - function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "断言失败: 期望条件为真"); - } +// assertTrue(condition, message) - 断言条件为真 +function assertTrue(condition, message) { + if (!condition) { + throw new Error(message || "断言失败: 期望条件为真"); } +} + +console.log("%c=== Content环境 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); + +let testResults = { + passed: 0, + failed: 0, + total: 0, +}; + +// 同步测试 + +// ============ GM_addElement/GM_addStyle 测试 ============ +console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); + +testSync("GM_addElement", () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-element", + }); + assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); + assert("gm-test-element", element.id, "元素ID应该正确"); + assert("DIV", element.tagName, "元素标签应该是DIV"); + console.log("返回元素:", element); + // 清理测试元素 + element.parentNode.removeChild(element); +}); + +testSync("GM_addStyle", () => { + const styleElement = GM_addStyle(` + .gm-style-test { + color: #10b981 !important; + } + `); + assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); + assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); + console.log("返回样式元素:", styleElement); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); +}); + +(async function () { + "use strict"; // ============ 早期脚本环境检查 ============ console.log("\n%c--- 早期脚本环境检查 ---", "color: orange; font-weight: bold;"); @@ -107,37 +153,11 @@ console.log("脚本注入到:", node.tagName); }); - // ============ GM_addElement/GM_addStyle 测试 ============ - console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", - }); - assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); - assert("gm-test-element", element.id, "元素ID应该正确"); - assert("DIV", element.tagName, "元素标签应该是DIV"); - console.log("返回元素:", element); - }); - - await test("GM_addStyle", () => { - const styleElement = GM_addStyle(` - .gm-style-test { - color: #10b981 !important; - } - `); - assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); - assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); - console.log("返回样式元素:", styleElement); - }); - // ============ GM_log 测试 ============ console.log("\n%c--- GM_log 测试 ---", "color: orange; font-weight: bold;"); await test("GM_log", () => { - GM_log("测试日志输出", { type: "test", value: 123 }); + GM_log("测试日志输出", "info", { type: "test", value: 123 }); // GM_log本身不返回值,只要不抛出异常就算成功 assertTrue(true, "GM_log应该能正常输出"); }); diff --git a/example/tests/early_test.js b/example/tests/early_test.js index 2e835e3e5..9ff6581d7 100644 --- a/example/tests/early_test.js +++ b/example/tests/early_test.js @@ -17,47 +17,91 @@ // @run-at document-start // ==/UserScript== -(async function () { - "use strict"; - - console.log("%c=== 早期脚本 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - - let testResults = { - passed: 0, - failed: 0, - total: 0, - }; - - // 测试辅助函数(支持同步和异步) - async function test(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } +// 测试辅助函数(支持同步和异步) +async function test(name, fn) { + testResults.total++; + try { + await fn(); + testResults.passed++; + console.log(`%c✓ ${name}`, "color: green;"); + return true; + } catch (error) { + testResults.failed++; + console.error(`%c✗ ${name}`, "color: red;", error); + return false; } - - // assert(expected, actual, message) - 比较两个值是否相等 - function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } +} + +function testSync(name, fn) { + testResults.total++; + try { + fn(); + testResults.passed++; + console.log(`%c✓ ${name}`, "color: green;"); + return true; + } catch (error) { + testResults.failed++; + console.error(`%c✗ ${name}`, "color: red;", error); + return false; } +} + +// assert(expected, actual, message) - 比较两个值是否相等 +function assert(expected, actual, message) { + if (expected !== actual) { + const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; + const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; + throw new Error(error); + } +} - // assertTrue(condition, message) - 断言条件为真 - function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "断言失败: 期望条件为真"); - } +// assertTrue(condition, message) - 断言条件为真 +function assertTrue(condition, message) { + if (!condition) { + throw new Error(message || "断言失败: 期望条件为真"); } +} + +console.log("%c=== 早期脚本 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); + +let testResults = { + passed: 0, + failed: 0, + total: 0, +}; + +// ============ GM_addElement/GM_addStyle 测试 ============ +console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); + +testSync("GM_addElement", () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-element", + }); + assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); + assert("gm-test-element", element.id, "元素ID应该正确"); + assert("DIV", element.tagName, "元素标签应该是DIV"); + console.log("返回元素:", element); + // 清理测试元素 + element.parentNode.removeChild(element); +}); + +testSync("GM_addStyle", () => { + const styleElement = GM_addStyle(` + .gm-style-test { + color: #10b981 !important; + } + `); + assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); + assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); + console.log("返回样式元素:", styleElement); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); +}); + +(async function () { + "use strict"; // ============ 早期脚本环境检查 ============ console.log("\n%c--- 早期脚本环境检查 ---", "color: orange; font-weight: bold;"); @@ -135,37 +179,11 @@ } }); - // ============ GM_addElement/GM_addStyle 测试 ============ - console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", - }); - assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); - assert("gm-test-element", element.id, "元素ID应该正确"); - assert("DIV", element.tagName, "元素标签应该是DIV"); - console.log("返回元素:", element); - }); - - await test("GM_addStyle", () => { - const styleElement = GM_addStyle(` - .gm-style-test { - color: #10b981 !important; - } - `); - assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); - assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); - console.log("返回样式元素:", styleElement); - }); - // ============ GM_log 测试 ============ console.log("\n%c--- GM_log 测试 ---", "color: orange; font-weight: bold;"); await test("GM_log", () => { - GM_log("测试日志输出", { type: "test", value: 123 }); + GM_log("测试日志输出", "info", { type: "test", value: 123 }); // GM_log本身不返回值,只要不抛出异常就算成功 assertTrue(true, "GM_log应该能正常输出"); }); diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index c2a2ca981..b16c9d25e 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -99,7 +99,7 @@ console.log("\n%c--- GM_log 测试 ---", "color: orange; font-weight: bold;"); await test("GM_log", () => { - GM_log("测试日志输出", { type: "test", value: 123 }); + GM_log("测试日志输出", "info", { type: "test", value: 123 }); // GM_log本身不返回值,只要不抛出异常就算成功 assertTrue(true, "GM_log应该能正常输出"); }); diff --git a/packages/message/common.ts b/packages/message/common.ts index 86a4c1ede..bffdd6a40 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -1,8 +1,10 @@ +import { randomMessageFlag } from "@App/pkg/utils/utils"; + // 避免页面载入后改动全域物件导致消息传递失败 export const MouseEventClone = MouseEvent; export const CustomEventClone = CustomEvent; -//@ts-ignore -const performanceClone = process.env.VI_TESTING === "true" ? simulatedEventTarget : performance; + +const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance; // 避免页面载入后改动 EventTarget.prototype 的方法导致消息传递失败 export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performanceClone); @@ -17,3 +19,71 @@ export const pageDispatchCustomEvent = (eventType: string, detail: any) => { }); return pageDispatchEvent(ev); }; + +// flag协商 +export function negotiateEventFlag(messageFlag: string, readyCount: number, onInit: (eventFlag: string) => void): void { + const eventFlag = randomMessageFlag(); + onInit(eventFlag); + // 监听 inject/scripting 发来的请求 eventFlag 的消息 + let ready = 0; + const fnEventFlagRequestHandler: EventListener = (ev: Event) => { + if (!(ev instanceof CustomEvent)) return; + + switch (ev.detail?.action) { + case "receivedEventFlag": + // 对方已收到 eventFlag + ready += 1; + if (ready >= readyCount) { + // 已收到两个环境的请求,移除监听 + pageRemoveEventListener(messageFlag, fnEventFlagRequestHandler); + } + break; + case "requestEventFlag": + // 广播通信 flag 给 inject/scripting + pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag }); + break; + } + }; + + pageAddEventListener(messageFlag, fnEventFlagRequestHandler); + + // 广播通信 flag 给 inject/scripting + pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag }); +} + +// 获取协商后的 eventFlag +export function getEventFlag(messageFlag: string, onReady: (eventFlag: string) => void) { + let eventFlag = ""; + const fnEventFlagListener: EventListener = (ev: Event) => { + if (!(ev instanceof CustomEvent)) return; + if (ev.detail?.action != "broadcastEventFlag") return; + eventFlag = ev.detail.eventFlag; + pageRemoveEventListener(messageFlag, fnEventFlagListener); + // 告知对方已收到 eventFlag + pageDispatchCustomEvent(messageFlag, { action: "receivedEventFlag" }); + onReady(eventFlag); + }; + + pageAddEventListener(messageFlag, fnEventFlagListener); + + // 基于同步机制,判断是否已经收到 eventFlag + // 如果没有收到,则主动请求一次 + if (!eventFlag) { + pageDispatchCustomEvent(messageFlag, { action: "requestEventFlag" }); + } +} + +export const createMouseEvent = + process.env.VI_TESTING === "true" + ? (type: string, eventInitDict?: MouseEventInit | undefined): MouseEvent => { + const ev = new MouseEventClone(type, eventInitDict); + eventInitDict = eventInitDict || {}; + for (const [key, value] of Object.entries(eventInitDict)) { + //@ts-ignore + if (ev[key] === undefined) ev[key] = value; + } + return ev; + } + : (type: string, eventInitDict?: MouseEventInit | undefined): MouseEvent => { + return new MouseEventClone(type, eventInitDict); + }; diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 3338a1716..5943bb632 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -6,11 +6,12 @@ import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts"; import { pageDispatchEvent, pageAddEventListener, - pageRemoveEventListener, pageDispatchCustomEvent, MouseEventClone, CustomEventClone, + createMouseEvent, } from "@Packages/message/common"; +import { ReadyWrap } from "@App/pkg/utils/ready-wrap"; // 避免页面载入后改动 Map.prototype 导致消息传递失败 const relatedTargetMap = new Map(); @@ -29,71 +30,38 @@ export class CustomEventPostMessage implements PostMessage { } } -export type PageMessaging = { - et: string; - bindReceiver?: () => void; - onReady?: (callback: () => any) => any; - setMessageTag: (tag: string) => void; - clearMessageTag: () => void; -}; - -export const createPageMessaging = (et: string) => { - const pageMessaging = { et } as PageMessaging; - let resolveFn: ((value: void | PromiseLike) => void) | null = null; - let promise = et - ? null - : new Promise((resolve) => { - resolveFn = resolve; - }); - pageMessaging.onReady = (callback: () => any) => { - if (pageMessaging.et) { - callback(); - } else { - promise?.then(callback); - } - }; - pageMessaging.setMessageTag = function (tag: string) { - if (this.et) throw new Error("pageMessaging.et has already been set."); - this.et = tag; - resolveFn?.(); - promise = null; - }; - pageMessaging.clearMessageTag = function () { - this.et = ""; - }; - return pageMessaging; -}; - // 使用CustomEvent来进行通讯, 可以在content与inject中传递一些dom对象 export class CustomEventMessage implements Message { EE = new EventEmitter(); readonly receiveFlag: string; readonly sendFlag: string; - readonly pageMessagingHandler: (event: Event) => any; // 关联dom目标 relatedTarget: Map = new Map(); + readyWrap: ReadyWrap = new ReadyWrap(); constructor( - private pageMessaging: PageMessaging, + messageFlag: string, protected readonly isInbound: boolean ) { - this.receiveFlag = `${isInbound ? DefinedFlags.inboundFlag : DefinedFlags.outboundFlag}${DefinedFlags.domEvent}`; - this.sendFlag = `${isInbound ? DefinedFlags.outboundFlag : DefinedFlags.inboundFlag}${DefinedFlags.domEvent}`; - this.pageMessagingHandler = (event: Event) => { - if (event instanceof MouseEventClone && event.movementX && event.relatedTarget) { + this.receiveFlag = `${messageFlag}${isInbound ? DefinedFlags.inboundFlag : DefinedFlags.outboundFlag}${DefinedFlags.domEvent}`; + this.sendFlag = `${messageFlag}${isInbound ? DefinedFlags.outboundFlag : DefinedFlags.inboundFlag}${DefinedFlags.domEvent}`; + pageAddEventListener(this.receiveFlag, (event: Event) => { + if (event instanceof MouseEventClone && event.movementX === 0 && event.cancelable) { + event.preventDefault(); // 告知另一端这边已准备好 + this.readyWrap.setReady(); // 两端已准备好,则 setReady() + } else if (event instanceof MouseEventClone && event.movementX && event.relatedTarget) { relatedTargetMap.set(event.movementX, event.relatedTarget); } else if (event instanceof CustomEventClone) { this.messageHandle(event.detail, new CustomEventPostMessage(this)); } - }; - } - - bindReceiver() { - if (!this.pageMessaging.et) throw new Error("bindReceiver() failed"); - const receiveFlag = `evt_${this.pageMessaging.et}_${this.receiveFlag}`; - pageRemoveEventListener(receiveFlag, this.pageMessagingHandler); // 避免重复 - pageAddEventListener(receiveFlag, this.pageMessagingHandler); + }); + const ev = createMouseEvent(this.sendFlag, { + movementX: 0, + cancelable: true, + }); + // 如另一端已准备好,则 setReady() + if (pageDispatchEvent(ev) === false) this.readyWrap.setReady(); } messageHandle(data: WindowMessageBody, target: PostMessage) { @@ -137,7 +105,7 @@ export class CustomEventMessage implements Message { connect(data: TMessage): Promise { return new Promise((resolve) => { - this.pageMessaging.onReady!(() => { + this.readyWrap.onReady(() => { const body: WindowMessageBody = { messageId: uuidv4(), type: "connect", @@ -151,13 +119,13 @@ export class CustomEventMessage implements Message { } nativeSend(detail: any) { - if (!this.pageMessaging.et) throw new Error("scripting.js is not ready or destroyed."); - pageDispatchCustomEvent(`evt_${this.pageMessaging.et}_${this.sendFlag}`, detail); + if (!this.readyWrap.isReady) throw new Error("custom_event_message is not ready."); + pageDispatchCustomEvent(this.sendFlag, detail); } sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { - this.pageMessaging.onReady!(() => { + this.readyWrap.onReady(() => { const messageId = uuidv4(); const body: WindowMessageBody = { messageId, @@ -179,7 +147,7 @@ export class CustomEventMessage implements Message { // 与content页的消息通讯实际是同步,此方法不需要经过background // 但是请注意中间不要有promise syncSendMessage(data: TMessage): TMessage { - if (!this.pageMessaging.et) throw new Error("scripting.js is not ready or destroyed."); + if (!this.readyWrap.isReady) throw new Error("custom_event_message is not ready."); const messageId = uuidv4(); const body: WindowMessageBody = { messageId, @@ -199,12 +167,12 @@ export class CustomEventMessage implements Message { } sendRelatedTarget(target: EventTarget): number { - if (!this.pageMessaging.et) throw new Error("scripting.js is not ready or destroyed."); + if (!this.readyWrap.isReady) throw new Error("custom_event_message is not ready."); // 特殊处理relatedTarget,返回id进行关联 // 先将relatedTarget转换成id发送过去 const id = (relateId = relateId === maxInteger ? 1 : relateId + 1); // 可以使用此种方式交互element - const ev = new MouseEventClone(`evt_${this.pageMessaging.et}_${this.sendFlag}`, { + const ev = createMouseEvent(this.sendFlag, { movementX: id, relatedTarget: target, }); diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b4f0a4a1a..f59b00abb 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; -import { createPageMessaging, CustomEventMessage } from "./custom_event_message"; +import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; -import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts"; import { uuidv4 } from "@App/pkg/utils/uuid"; let inboundMessage: CustomEventMessage; @@ -13,60 +12,17 @@ let client: CustomEventMessage; const nextTick = () => Promise.resolve().then(() => {}); const setupGlobal = () => { - const testFlag = uuidv4(); - const testPageMessaging = createPageMessaging(testFlag); + const testFlag = `${uuidv4()}::server.test`; + // 创建 scripting 和 inject / content 之间的消息通道 - inboundMessage = new CustomEventMessage(testPageMessaging, true); // scripting 端 - outboundMessage = new CustomEventMessage(testPageMessaging, false); // inject / content 端 - inboundMessage.bindReceiver(); - outboundMessage.bindReceiver(); + inboundMessage = new CustomEventMessage(testFlag, true); // scripting 端 + outboundMessage = new CustomEventMessage(testFlag, false); // inject / content 端 // 服务端使用 scripting 消息 server = new Server("api", inboundMessage); // 客户端使用 inject / content 消息 client = outboundMessage; - - //@ts-ignore - const window = simulatedEventTarget; - - // 模拟消息传递 - 从 inject 到 content - window.dispatchEvent.mockImplementation((event: Event) => { - if (event instanceof CustomEvent) { - const eventType = event.type; - if (eventType.includes(testFlag)) { - let targetEventType: string; - let messageThis: CustomEventMessage; - let messageThat: CustomEventMessage; - // 根据事件类型确定目标消息处理器 - if (eventType.includes(DefinedFlags.inboundFlag)) { - // inject -> content - targetEventType = eventType.replace(DefinedFlags.inboundFlag, DefinedFlags.outboundFlag); - messageThis = inboundMessage; - messageThat = outboundMessage; - } else if (eventType.includes(DefinedFlags.outboundFlag)) { - // content -> inject - targetEventType = eventType.replace(DefinedFlags.outboundFlag, DefinedFlags.inboundFlag); - messageThis = outboundMessage; - messageThat = inboundMessage; - } else { - throw new Error("test mock failed"); - } - nextTick().then(() => { - messageThis.messageHandle(event.detail, { - postMessage: (data: any) => { - // 响应 - const responseEvent = new CustomEvent(targetEventType, { detail: data }); - messageThat.messageHandle(responseEvent.detail, { - postMessage: vi.fn(), - }); - }, - }); - }); - } - } - return true; - }); }; beforeEach(() => { @@ -257,7 +213,7 @@ describe("Server", () => { }); it.concurrent("应该自动为 Group 名称添加斜杠", async () => { - const mockHandler = vi.fn().mockResolvedValue("auto slash response"); + const mockHandler = vi.fn().mockResolvedValue("nested response"); // 测试不带斜杠的情况 const group1 = server.group("slash-group1"); @@ -661,10 +617,10 @@ describe("Server", () => { it.concurrent("应该能够处理空参数", async () => { const mockHandler = vi.fn().mockResolvedValue("empty response"); - server.on("on-test-empty", mockHandler); + server.on("on-empty", mockHandler); const response = await client.sendMessage({ - action: "api/on-test-empty", + action: "api/on-empty", data: null, }); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 18c28f7ba..b34ba1581 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -685,6 +685,7 @@ export default class GMApi extends GM_Base { { textContent: css, }, + isContent, ], }, }); diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index c2796039b..68759420b 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -6,8 +6,9 @@ import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; import { addStyleSheet, definePropertyListener } from "./utils"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; -import { pageAddEventListener, pageDispatchEvent, type ScriptEnvTag } from "@Packages/message/common"; +import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; +import type { ScriptEnvTag } from "@Packages/message/consts"; export type ExecScriptEntry = { scriptLoadInfo: TScriptInfo; @@ -91,7 +92,7 @@ export class ScriptExecutor { const envLoadCompleteEvtName = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; // 监听 脚本加载 // 适用于此「通知环境加载完成」代码执行后的脚本加载 - pageAddEventListener(scriptLoadCompleteEvtName, (ev) => { + const scriptLoadCompleteHandler: EventListener = (ev: Event) => { const detail = (ev as CustomEvent).detail as { scriptFlag: string; scriptInfo: ScriptLoadInfo; @@ -116,7 +117,8 @@ export class ScriptExecutor { } this.execEarlyScript(scriptFlag, detail.scriptInfo, envInfo); } - }); + }; + pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); // 通知 环境 加载完成 // 适用于此「通知环境加载完成」代码执行前的脚本加载 const ev = new CustomEvent(envLoadCompleteEvtName); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts new file mode 100644 index 000000000..93954a77f --- /dev/null +++ b/src/app/service/content/script_runtime.ts @@ -0,0 +1,98 @@ +import { type Server } from "@Packages/message/server"; +import type { Message } from "@Packages/message/types"; +import { ExternalWhitelist } from "@App/app/const"; +import { sendMessage } from "@Packages/message/client"; +import { initEnvInfo, type ScriptExecutor } from "./script_executor"; +import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { EmitEventRequest } from "../service_worker/types"; +import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; +import type { ScriptEnvTag } from "@Packages/message/consts"; + +export class ScriptRuntime { + constructor( + private readonly scripEnvTag: ScriptEnvTag, + private readonly server: Server, + private readonly msg: Message, + private readonly scriptExecutor: ScriptExecutor, + private readonly messageFlag: string + ) {} + + init() { + this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { + // 转发给脚本 + this.scriptExecutor.emitEvent(data); + }); + this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { + this.scriptExecutor.valueUpdate(data); + }); + + this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { + // 监听事件 + this.startScripts(data.scripts, data.envInfo); + }); + + // 检查early-start的脚本 + this.scriptExecutor.checkEarlyStartScript(this.scripEnvTag, initEnvInfo); + } + + startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { + this.scriptExecutor.startScripts(scripts, envInfo); + } + + externalMessage() { + // 对外接口白名单 + const hostname = window.location.hostname; + if ( + ExternalWhitelist.some( + // 如果当前页面的 hostname 是白名单的网域或其子网域 + (t) => hostname.endsWith(t) && (hostname.length === t.length || hostname.endsWith(`.${t}`)) + ) + ) { + const msg = this.msg; + // 注入 + const external: External = window.external || (window.external = {} as External); + const scriptExpose: App.ExternalScriptCat = { + isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { + sendMessage(msg, "content/script/isInstalled", { + name, + namespace, + }).then(callback); + }, + }; + try { + external.Scriptcat = scriptExpose; + } catch { + // 无法注入到 external,忽略 + } + const exposedTM = external.Tampermonkey; + const isInstalledTM = exposedTM?.isInstalled; + const isInstalledSC = scriptExpose.isInstalled; + if (isInstalledTM && exposedTM?.getVersion && exposedTM.openOptions) { + // 当TM和SC同时启动的特殊处理:如TM没有安装,则查SC的安装状态 + try { + exposedTM.isInstalled = ( + name: string, + namespace: string, + callback: (res: App.IsInstalledResponse | undefined) => unknown + ) => { + isInstalledTM(name, namespace, (res) => { + if (res?.installed) callback(res); + else + isInstalledSC(name, namespace, (res) => { + callback(res); + }); + }); + }; + } catch { + // 忽略错误 + } + } else { + try { + external.Tampermonkey = scriptExpose; + } catch { + // 无法注入到 external,忽略 + } + } + } + } +} diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts new file mode 100644 index 000000000..a8e5eb9e2 --- /dev/null +++ b/src/app/service/content/scripting.ts @@ -0,0 +1,168 @@ +import { Client, sendMessage } from "@Packages/message/client"; +import { type CustomEventMessage } from "@Packages/message/custom_event_message"; +import { forwardMessage, type Server } from "@Packages/message/server"; +import type { MessageSend } from "@Packages/message/types"; +import { RuntimeClient } from "../service_worker/client"; +import { makeBlobURL } from "@App/pkg/utils/utils"; +import type { Logger } from "@App/app/repo/logger"; +import LoggerCore from "@App/app/logger/core"; +import type { ValueUpdateDataEncoded } from "./types"; + +// scripting页的处理 +export default class ScriptingRuntime { + constructor( + // 监听来自service_worker的消息 + private readonly extServer: Server, + // 监听来自inject的消息 + private readonly server: Server, + // 发送给扩展service_worker的通信接口 + private readonly senderToExt: MessageSend, + // 发送给 content的消息接口 + private readonly senderToContent: CustomEventMessage, + // 发送给inject的消息接口 + private readonly senderToInject: CustomEventMessage + ) {} + + // 广播消息给 content 和 inject + broadcastToPage(action: string, data?: any): Promise { + return Promise.all([ + sendMessage(this.senderToContent, "content/" + action, data), + sendMessage(this.senderToInject, "inject/" + action, data), + ]).then(() => undefined); + } + + init() { + this.extServer.on("runtime/emitEvent", (data) => { + // 转发给inject和content + return this.broadcastToPage("runtime/emitEvent", data); + }); + this.extServer.on("runtime/valueUpdate", (data) => { + // 转发给inject和content + return this.broadcastToPage("runtime/valueUpdate", data); + }); + this.server.on("logger", (data: Logger) => { + LoggerCore.logger().log(data.level, data.message, data.label); + }); + + // ================================ + // 来自 service_worker 的投递:storage 广播(类似 UDP) + // ================================ + + // 接收 service_worker 的 chrome.storage.local 值改变通知 (一对多广播) + // 类似 UDP 原理,service_worker 不会有任何「等待处理」 + // 由于 changes 会包括新旧值 (Chrome: JSON serialization, Firefox: Structured Clone) + // 因此需要注意资讯量不要过大导致 onChanged 的触发过慢 + chrome.storage.local.onChanged.addListener((changes) => { + if (changes["valueUpdateDelivery"]?.newValue) { + // 转发给 content 和 inject + this.broadcastToPage( + "runtime/valueUpdate", + changes["valueUpdateDelivery"]?.newValue.sendData as ValueUpdateDataEncoded + ); + } + }); + + forwardMessage("serviceWorker", "script/isInstalled", this.server, this.senderToExt); + forwardMessage( + "serviceWorker", + "runtime/gmApi", + this.server, + this.senderToExt, + (data: { api: string; params: any; uuid: string }) => { + // 拦截关注的api + switch (data.api) { + case "CAT_createBlobUrl": { + const file = data.params[0] as File; + const url = makeBlobURL({ blob: file, persistence: false }) as string; + return url; + } + case "CAT_fetchBlob": { + return fetch(data.params[0]).then((res) => res.blob()); + } + case "CAT_fetchDocument": { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", data.params[0]); + xhr.onload = () => { + const nodeId = (this.senderToInject as CustomEventMessage).sendRelatedTarget(xhr.response); + resolve(nodeId); + }; + xhr.send(); + }); + } + case "GM_addElement": { + const [parentNodeId, tagName, tmpAttr, isContent] = data.params; + let attr = { ...tmpAttr }; + let parentNode: Node | undefined; + + // 根据来源选择不同的消息桥(content / inject) + const msg = isContent ? this.senderToContent : this.senderToInject; + + if (parentNodeId) { + parentNode = msg.getAndDelRelatedTarget(parentNodeId) as Node | undefined; + } + const el = document.createElement(tagName); + + let textContent = ""; + if (attr) { + if (attr.textContent) { + textContent = attr.textContent; + delete attr.textContent; + } + } else { + attr = {}; + } + for (const key of Object.keys(attr)) { + el.setAttribute(key, attr[key]); + } + if (textContent) { + el.textContent = textContent; + } + (parentNode || document.head || document.body || document.querySelector("*")).appendChild(el); + const nodeId = msg.sendRelatedTarget(el); + return nodeId; + } + case "GM_log": + // 拦截GM_log,打印到控制台 + // 由于某些页面会处理掉console.log,所以丢到这里来打印 + switch (data.params.length) { + case 1: + console.log(data.params[0]); + break; + case 2: + console.log("[" + data.params[1] + "]", data.params[0]); + break; + case 3: + console.log("[" + data.params[1] + "]", data.params[0], data.params[2]); + break; + } + break; + } + return false; + } + ); + } + + pageLoad() { + const client = new RuntimeClient(this.senderToExt); + // 向service_worker请求脚本列表及环境信息 + client.pageLoad().then((o) => { + if (!o.ok) return; + const { injectScriptList, contentScriptList, envInfo } = o; + + // 向页面 发送脚本列表及环境信息 + if (contentScriptList.length) { + const contentClient = new Client(this.senderToContent, "content"); + // 根据@inject-into content过滤脚本 + contentClient.do("pageLoad", { scripts: contentScriptList, envInfo }); + } + + if (injectScriptList.length) { + const injectClient = new Client(this.senderToInject, "inject"); + // 根据@inject-into content过滤脚本 + injectClient.do("pageLoad", { scripts: injectScriptList, envInfo }); + } + }); + } +} diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index b52bf484d..305bca6db 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -50,7 +50,6 @@ import type { CompiledResource, ResourceType } from "@App/app/repo/resource"; import { CompiledResourceDAO } from "@App/app/repo/resource"; import { setOnTabURLChanged } from "./url_monitor"; import { scriptToMenu, type TPopupPageLoadInfo } from "./popup_scriptmenu"; -import { uuidv4 } from "@App/pkg/utils/uuid"; const ORIGINAL_URLMATCH_SUFFIX = "{ORIGINAL}"; // 用于标记原始URLPatterns的后缀 @@ -111,7 +110,6 @@ export class RuntimeService { sitesLoaded: Set = new Set(); updateSitesBusy: boolean = false; - loadingInitFlagsPromise: Promise | undefined; loadingInitProcessPromise: Promise | undefined; initialCompiledResourcePromise: Promise | undefined; @@ -128,15 +126,6 @@ export class RuntimeService { private scriptDAO: ScriptDAO, private localStorageDAO: LocalStorageDAO ) { - this.loadingInitFlagsPromise = this.localStorageDAO - .get("scriptInjectMessageFlag") - .then((res) => { - runtimeGlobal.messageFlag = res?.value || this.generateMessageFlag(); - if (runtimeGlobal.messageFlag !== res?.value) { - return this.localStorageDAO.save({ key: "scriptInjectMessageFlag", value: runtimeGlobal.messageFlag }); - } - }) - .catch(console.error); this.logger = LoggerCore.logger({ component: "runtime" }); // 使用中间件 @@ -556,11 +545,10 @@ export class RuntimeService { this.initReady = (async () => { // 取得初始值 或 等待各种异步同时进行的初始化 (_1, _2, ...) - const [isUserScriptsAvailable, isLoadScripts, strBlacklist, _1, _2, _3] = await Promise.all([ + const [isUserScriptsAvailable, isLoadScripts, strBlacklist, _1, _2] = await Promise.all([ checkUserScriptsAvailable(), this.systemConfig.getEnableScript(), this.systemConfig.getBlacklist(), - this.loadingInitFlagsPromise, // messageFlag 初始化等待 this.loadingInitProcessPromise, // 初始化程序等待 this.initUserAgentData(), // 初始化:userAgentData ]); @@ -679,25 +667,14 @@ export class RuntimeService { runtimeGlobal.registered = false; // 重置 flag 避免取消注册失败 // 即使注册失败,通过重置 flag 可避免错误地呼叫已取消注册的Script - runtimeGlobal.messageFlag = this.generateMessageFlag(); await Promise.allSettled([ chrome.userScripts?.unregister(), chrome.scripting.unregisterContentScripts(), - this.localStorageDAO.save({ key: "scriptInjectMessageFlag", value: runtimeGlobal.messageFlag }), chrome.storage.session.set({ unregisterUserscriptsFlag: `${Date.now()}.${Math.random()}` }), ]); } } - // 生成messageFlag - generateMessageFlag(): string { - return uuidv4(); - } - - getMessageFlag() { - return runtimeGlobal.messageFlag; - } - async buildAndSaveCompiledResourceFromScript(script: Script, withCode: boolean = false) { const scriptRes = withCode ? await this.script.buildScriptRunResource(script) : buildScriptRunResourceBasic(script); const resources = withCode ? scriptRes.resource : await this.resource.getScriptResources(scriptRes, true); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 48cf5143a..d1ce2573a 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -79,7 +79,7 @@ export class ValueService { async pushValueToTab(sendData: T) { chrome.storage.local.set({ valueUpdateDelivery: { - rId: `${Date.now()}.${Math.random()}`, + rId: `${Date.now()}.${Math.random()}`, // 用于区分不同的更新,确保 chrome.storage.local.onChanged 必能触发 sendData, }, }); diff --git a/src/content.ts b/src/content.ts index 263fcb207..130de1d13 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,183 +1,32 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; -import { CustomEventMessage, createPageMessaging } from "@Packages/message/custom_event_message"; -import { pageAddEventListener, pageDispatchCustomEvent, pageDispatchEvent } from "@Packages/message/common"; +import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { Server } from "@Packages/message/server"; +import { ScriptExecutor } from "./app/service/content/script_executor"; +import type { Message } from "@Packages/message/types"; +import { getEventFlag } from "@Packages/message/common"; +import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; -import { uuidv5 } from "./pkg/utils/uuid"; -import { initEnvInfo, ScriptExecutor } from "./app/service/content/script_executor"; -import type { ValueUpdateDataEncoded } from "./app/service/content/types"; -import type { TClientPageLoadInfo } from "./app/repo/scripts"; -//@ts-ignore -const MessageFlag = uuidv5(`${performance.timeOrigin}`, process.env.SC_RANDOM_KEY); +const messageFlag = process.env.SC_RANDOM_KEY!; -// ================================ -// 常量与全局状态 -// ================================ +getEventFlag(messageFlag, (eventFlag: string) => { + const isContent = typeof chrome.runtime?.sendMessage === "function"; + const scriptEnvTag = isContent ? ScriptEnvTag.content : ScriptEnvTag.inject; -// 判断当前是否运行在 USER_SCRIPT 环境 (content环境) -const isContent = typeof chrome.runtime?.sendMessage === "function"; -const scriptEnvTag = isContent ? ScriptEnvTag.content : ScriptEnvTag.inject; + const msg: Message = new CustomEventMessage(`${eventFlag}${scriptEnvTag}`, false); -// 用于通知页面:content executor 已准备好 -const executorEnvReadyKey = uuidv5("scriptcat-executor-ready", MessageFlag); - -// 页面通信通道(event token 会在握手后设置) -const scriptingMessaging = createPageMessaging(""); // injectFlagEvt -const pageMessaging = createPageMessaging(""); // `${injectFlagEvt}_${scriptEnvTag}` - -// scripting <-> content 的双向消息桥 -const msg = new CustomEventMessage(pageMessaging, false); - -// 日志系统(仅在 scripting 环境打印) -const logger = new LoggerCore({ - writer: new MessageWriter(msg, "scripting/logger"), - consoleLevel: "none", - labels: { env: "content", href: window.location.href }, -}); - -// 脚本执行器 -const scriptExecutor = new ScriptExecutor(msg); - -// 一次性绑定函数(绑定完成后会被置空) -let bindScriptingDeliveryOnce: (() => void) | null = null; - -// ================================ -// 工具函数:token 与握手 -// ================================ - -// 确保 scripting messaging 已就绪 -const requireScriptingToken = (): string => { - if (!scriptingMessaging.et) { - // scriptingMessaging 尚未准备好或已被销毁 - throw new Error("scriptingMessaging is not ready or destroyed"); - } - return scriptingMessaging.et; -}; - -// 重置所有页面通信 token(用于反注册脚本) -const resetMessagingTokens = () => { - scriptingMessaging.clearMessageTag(); - pageMessaging.clearMessageTag(); -}; - -// 根据 injectFlagEvt 设置双方通信 token(仅允许调用一次) -const setMessagingTokens = (injectFlagEvt: string) => { - scriptingMessaging.setMessageTag(injectFlagEvt); - pageMessaging.setMessageTag(`${injectFlagEvt}_${scriptEnvTag}`); -}; - -// 通知 scripting 侧:content 已完成初始化 -const acknowledgeScriptingReady = (injectFlagEvt: string) => { - pageDispatchCustomEvent(injectFlagEvt, { - [`emitterKeyFor${injectFlagEvt}`]: isContent ? 2 : 1, - }); -}; - -// ================================ -// 消息分发处理 -// ================================ - -// 处理 scripting -> content 的消息 -const handleDeliveryMessage = (tag: string, value: any) => { - switch (tag) { - case "localStorage:scriptInjectMessageFlag": { - // 反注册所有脚本时,中断页面通信 - resetMessagingTokens(); - return; - } - - case "valueUpdateDelivery": { - // storage / value 更新同步 - const sendData = value.sendData as ValueUpdateDataEncoded; - scriptExecutor.valueUpdate(sendData); - return; - } - - case "scripting/runtime/emitEvent": { - // scripting 主动触发事件 - scriptExecutor.emitEvent(value); - return; - } - - case "pageLoad": { - // 页面加载完成,启动匹配的脚本 - const info = value as TClientPageLoadInfo; - if (!info.ok) return; - - const { contentScriptList, envInfo } = info; - logger.logger().debug("content start - pageload"); - scriptExecutor.startScripts(contentScriptList, envInfo); - return; - } - - default: - // 未识别的消息类型直接忽略 - return; - } -}; - -// ================================ -// 页面通信绑定与握手 -// ================================ - -// 监听 scripting 发来的 delivery 消息 -const bindScriptingDeliveryChannel = () => { - const token = requireScriptingToken(); - - pageAddEventListener(`evt_${token}_deliveryMessage`, (ev) => { - if (!(ev instanceof CustomEvent)) return; - - const { tag, value } = ev.detail ?? {}; - handleDeliveryMessage(tag, value); - }); -}; - -// 建立 scripting <-> content 的握手流程 -const setupHandshake = () => { - // 准备一次性绑定函数 - bindScriptingDeliveryOnce = () => { - bindScriptingDeliveryOnce = null; - bindScriptingDeliveryChannel(); - }; - - // 等待 scripting 注入完成并发送 injectFlagEvt (仅调用一次) - pageAddEventListener(executorEnvReadyKey, (ev) => { - if (!(ev instanceof CustomEvent)) return; - - const injectFlagEvt = ev.detail?.injectFlagEvt; - - // 已初始化 / 参数非法 / 已绑定过 → 忽略 - if (scriptingMessaging.et || typeof injectFlagEvt !== "string" || !bindScriptingDeliveryOnce) { - return; - } - - // 接受此次握手 - ev.preventDefault(); - - // 初始化通信 token - setMessagingTokens(injectFlagEvt); - msg.bindReceiver(); - - logger.logger().debug("content start - init"); - - // 建立消息监听 - bindScriptingDeliveryOnce(); - - // 回传 ready 信号 - acknowledgeScriptingReady(injectFlagEvt); + // 初始化日志组件 + const logger = new LoggerCore({ + writer: new MessageWriter(msg, "scripting/logger"), + consoleLevel: process.env.NODE_ENV === "development" ? "debug" : "none", // 只让日志在scripting环境中打印 + labels: { env: "content", href: window.location.href }, }); -}; - -// ================================ -// 启动流程 -// ================================ -// 检查 early-start 脚本 -scriptExecutor.checkEarlyStartScript(scriptEnvTag, initEnvInfo); + logger.logger().debug("content start"); -// 建立握手与通信绑定 -setupHandshake(); - -// 主动触发 ready 事件,请求 scripting 建立连接 -pageDispatchEvent(new CustomEvent(executorEnvReadyKey)); + const server = new Server("content", msg); + const scriptExecutor = new ScriptExecutor(msg); + const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, messageFlag); + runtime.init(); +}); diff --git a/src/inject.ts b/src/inject.ts index 3526f96a4..3350b866a 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,272 +1,35 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; -import { CustomEventMessage, createPageMessaging } from "@Packages/message/custom_event_message"; -import { pageAddEventListener, pageDispatchCustomEvent, pageDispatchEvent } from "@Packages/message/common"; -import { ScriptEnvTag } from "@Packages/message/consts"; -import { uuidv5 } from "./pkg/utils/uuid"; -import { initEnvInfo, ScriptExecutor } from "./app/service/content/script_executor"; -import type { ValueUpdateDataEncoded } from "./app/service/content/types"; -import type { TClientPageLoadInfo } from "./app/repo/scripts"; +import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { Server } from "@Packages/message/server"; +import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; -import { sendMessage } from "@Packages/message/client"; -import { ExternalWhitelist } from "@App/app/const"; - -//@ts-ignore -const MessageFlag = uuidv5(`${performance.timeOrigin}`, process.env.SC_RANDOM_KEY); - -// ================================ -// 常量与全局状态 -// ================================ - -// 判断当前是否运行在 USER_SCRIPT 环境 (content环境) -const isContent = typeof chrome.runtime?.sendMessage === "function"; -const scriptEnvTag = isContent ? ScriptEnvTag.content : ScriptEnvTag.inject; - -// 用于通知页面:inject executor 已准备好 -const executorEnvReadyKey = uuidv5("scriptcat-executor-ready", MessageFlag); - -// 页面通信通道(event token 会在握手后设置) -const scriptingMessaging = createPageMessaging(""); // injectFlagEvt -const pageMessaging = createPageMessaging(""); // `${injectFlagEvt}_${scriptEnvTag}` - -// scripting <-> inject 的双向消息桥 -const msg = new CustomEventMessage(pageMessaging, false); - -// 日志系统(仅在 scripting 环境打印) -const logger = new LoggerCore({ - writer: new MessageWriter(msg, "scripting/logger"), - consoleLevel: "none", - labels: { env: "inject", href: window.location.href }, -}); - -// 脚本执行器 -const scriptExecutor = new ScriptExecutor(msg); - -// 一次性绑定函数(绑定完成后会被置空) -let bindScriptingDeliveryOnce: (() => void) | null = null; - -// ================================ -// 工具函数:token 与握手 -// ================================ - -// 确保 scripting messaging 已就绪 -const requireScriptingToken = (): string => { - if (!scriptingMessaging.et) { - // scriptingMessaging 尚未准备好或已被销毁 - throw new Error("scriptingMessaging is not ready or destroyed"); - } - return scriptingMessaging.et; -}; - -// 重置所有页面通信 token(用于反注册脚本) -const resetMessagingTokens = () => { - scriptingMessaging.clearMessageTag(); - pageMessaging.clearMessageTag(); -}; - -// 根据 injectFlagEvt 设置双方通信 token(仅允许调用一次) -const setMessagingTokens = (injectFlagEvt: string) => { - scriptingMessaging.setMessageTag(injectFlagEvt); - pageMessaging.setMessageTag(`${injectFlagEvt}_${scriptEnvTag}`); -}; - -// 通知 scripting 侧:inject 已完成初始化 -const acknowledgeScriptingReady = (injectFlagEvt: string) => { - pageDispatchCustomEvent(injectFlagEvt, { - [`emitterKeyFor${injectFlagEvt}`]: isContent ? 2 : 1, - }); -}; - -// ================================ -// 对外接口:external 注入 -// ================================ - -// 判断当前 hostname 是否命中白名单(含子域名) -function isExternalWhitelisted(hostname: string) { - return ExternalWhitelist.some( - (t) => hostname.endsWith(t) && (hostname.length === t.length || hostname.endsWith(`.${t}`)) - ); -} - -// 生成暴露给页面的 Scriptcat 外部接口 -function createScriptcatExpose(pageMsg: Message) { - const scriptExpose: App.ExternalScriptCat = { - isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { - sendMessage(pageMsg, "scripting/script/isInstalled", { name, namespace }).then(callback); - }, - }; - return scriptExpose; -} - -// 尝试写入 external,失败则忽略 -function safeSetExternal(external: any, key: string, value: T) { - try { - external[key] = value; - return true; - } catch { - // 无法注入到 external,忽略 - return false; - } -} - -// 当 TM 与 SC 同时存在时的兼容处理:TM 未安装脚本时回退查询 SC -function patchTampermonkeyIsInstalled(external: any, scriptExpose: App.ExternalScriptCat) { - const exposedTM = external.Tampermonkey; - const isInstalledTM = exposedTM?.isInstalled; - const isInstalledSC = scriptExpose.isInstalled; - - // 满足这些字段时,认为是较完整的 TM 对象 - if (isInstalledTM && exposedTM?.getVersion && exposedTM.openOptions) { - try { - exposedTM.isInstalled = ( - name: string, - namespace: string, - callback: (res: App.IsInstalledResponse | undefined) => unknown - ) => { - isInstalledTM(name, namespace, (res: App.IsInstalledResponse | undefined) => { - if (res?.installed) callback(res); - else isInstalledSC(name, namespace, callback); - }); - }; - } catch { - // 忽略错误 - } - return true; - } - - return false; -} - -// inject 环境 pageLoad 后执行:按白名单对页面注入 external 接口 -function onInjectPageLoaded(pageMsg: Message) { - const hostname = window.location.hostname; - - // 不在白名单则不对外暴露接口 - if (!isExternalWhitelisted(hostname)) return; - - // 确保 external 存在 - const external: External = (window.external || (window.external = {} as External)) as External; - - // 创建 Scriptcat 暴露对象 - const scriptExpose = createScriptcatExpose(pageMsg); - - // 尝试设置 external.Scriptcat - safeSetExternal(external, "Scriptcat", scriptExpose); - - // 如果页面已有 Tampermonkey,则做兼容补丁;否则将 Tampermonkey 也指向 Scriptcat 接口 - const patched = patchTampermonkeyIsInstalled(external, scriptExpose); - if (!patched) { - safeSetExternal(external, "Tampermonkey", scriptExpose); - } -} - -// ================================ -// 消息分发处理 -// ================================ - -// 处理 scripting -> inject 的消息 -const handleDeliveryMessage = (tag: string, value: any) => { - switch (tag) { - case "localStorage:scriptInjectMessageFlag": { - // 反注册所有脚本时,中断页面通信 - resetMessagingTokens(); - return; - } - - case "valueUpdateDelivery": { - // storage / value 更新同步 - const sendData = value.sendData as ValueUpdateDataEncoded; - scriptExecutor.valueUpdate(sendData); - return; - } - - case "scripting/runtime/emitEvent": { - // scripting 主动触发事件 - scriptExecutor.emitEvent(value); - return; - } - - case "pageLoad": { - // 页面加载完成,启动匹配的脚本,并在需要时注入 external - const info = value as TClientPageLoadInfo; - if (!info.ok) return; - - const { injectScriptList, envInfo } = info; - logger.logger().debug("inject start - pageload"); - scriptExecutor.startScripts(injectScriptList, envInfo); - - // pageLoad 后再做 external 注入,避免过早修改页面对象 - onInjectPageLoaded(msg); - return; - } - - default: - // 未识别的消息类型直接忽略 - return; - } -}; - -// ================================ -// 页面通信绑定与握手 -// ================================ - -// 监听 scripting 发来的 delivery 消息 -const bindScriptingDeliveryChannel = () => { - const token = requireScriptingToken(); - - pageAddEventListener(`evt_${token}_deliveryMessage`, (ev) => { - if (!(ev instanceof CustomEvent)) return; - - const { tag, value } = ev.detail ?? {}; - handleDeliveryMessage(tag, value); - }); -}; - -// 建立 scripting <-> inject 的握手流程 -const setupHandshake = () => { - // 准备一次性绑定函数 - bindScriptingDeliveryOnce = () => { - bindScriptingDeliveryOnce = null; - bindScriptingDeliveryChannel(); - }; - - // 等待 scripting 注入完成并发送 injectFlagEvt (仅调用一次) - pageAddEventListener(executorEnvReadyKey, (ev) => { - if (!(ev instanceof CustomEvent)) return; - - const injectFlagEvt = ev.detail?.injectFlagEvt; - - // 已初始化 / 参数非法 / 已绑定过 → 忽略 - if (scriptingMessaging.et || typeof injectFlagEvt !== "string" || !bindScriptingDeliveryOnce) { - return; - } - - // 接受此次握手 - ev.preventDefault(); +import { getEventFlag } from "@Packages/message/common"; +import { ScriptRuntime } from "./app/service/content/script_runtime"; +import { ScriptEnvTag } from "@Packages/message/consts"; - // 初始化通信 token - setMessagingTokens(injectFlagEvt); - msg.bindReceiver(); +const messageFlag = process.env.SC_RANDOM_KEY!; - logger.logger().debug("inject start - init"); +getEventFlag(messageFlag, (eventFlag: string) => { + const isContent = typeof chrome.runtime?.sendMessage === "function"; + const scriptEnvTag = isContent ? ScriptEnvTag.content : ScriptEnvTag.inject; - // 建立消息监听 - bindScriptingDeliveryOnce(); + const msg: Message = new CustomEventMessage(`${eventFlag}${scriptEnvTag}`, false); - // 回传 ready 信号 - acknowledgeScriptingReady(injectFlagEvt); + // 加载logger组件 + const logger = new LoggerCore({ + writer: new MessageWriter(msg, "scripting/logger"), + consoleLevel: process.env.NODE_ENV === "development" ? "debug" : "none", // 只让日志在scripting环境中打印 + labels: { env: "inject", href: window.location.href }, }); -}; - -// ================================ -// 启动流程 -// ================================ -// 检查 early-start 脚本 -scriptExecutor.checkEarlyStartScript(scriptEnvTag, initEnvInfo); + logger.logger().debug("inject start"); -// 建立握手与通信绑定 -setupHandshake(); + const server = new Server("inject", msg); + const scriptExecutor = new ScriptExecutor(msg); + const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, messageFlag); + runtime.init(); -// 主动触发 ready 事件,请求 scripting 建立连接 -pageDispatchEvent(new CustomEvent(executorEnvReadyKey)); + // inject环境,直接判断白名单,注入对外接口 + runtime.externalMessage(); +}); diff --git a/src/pkg/utils/ready-wrap.ts b/src/pkg/utils/ready-wrap.ts new file mode 100644 index 000000000..ef2edb36d --- /dev/null +++ b/src/pkg/utils/ready-wrap.ts @@ -0,0 +1,16 @@ +export class ReadyWrap { + public isReady: boolean = false; + private resolve: ((value: void | PromiseLike) => void) | null = null; + private promise: Promise | null = new Promise((resolve) => { + this.resolve = resolve; + }); + onReady(fn: () => any) { + this.isReady ? fn() : this.promise!.then(fn); + } + setReady() { + this.resolve?.(); + this.isReady = true; + this.resolve = null; + this.promise = null; + } +} diff --git a/src/scripting.ts b/src/scripting.ts index 7d8c22c9f..9ff6d0bb1 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -1,315 +1,39 @@ +import { ExtensionMessage } from "@Packages/message/extension_message"; import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; -import { CustomEventMessage, createPageMessaging } from "@Packages/message/custom_event_message"; -import { pageAddEventListener, pageDispatchCustomEvent } from "@Packages/message/common"; -import { uuidv5 } from "./pkg/utils/uuid"; -import { randomMessageFlag, makeBlobURL } from "@App/pkg/utils/utils"; -import { ExtensionMessage } from "@Packages/message/extension_message"; -import type { Message, MessageSend } from "@Packages/message/types"; -import { Server, forwardMessage } from "@Packages/message/server"; -import { RuntimeClient } from "@App/app/service/service_worker/client"; -import type { Logger } from "@App/app/repo/logger"; -import { MessageDelivery } from "./message-delivery"; -import { ScriptEnvTag, ScriptEnvType } from "@Packages/message/consts"; - -//@ts-ignore -const MessageFlag = uuidv5(`${performance.timeOrigin}`, process.env.SC_RANDOM_KEY); - -// ================================ -// 常量与全局状态 -// ================================ - -// 记录脚本 uuid 来自 inject(1) / content(2) -const uuids = new Map(); - -// 与 service_worker 通信的 sender(scripting -> service_worker) -const senderToExt: Message = new ExtensionMessage(false); - -// scripting <-> inject/content 的 page messaging(token 在握手后设置) -const scriptExecutorMsgIT = createPageMessaging(""); -const scriptExecutorMsgCT = createPageMessaging(""); - -// scripting <-> inject/content 的双向消息桥 -const scriptExecutorMsgTxIT = new CustomEventMessage(scriptExecutorMsgIT, true); // 双向:scripting <-> inject -const scriptExecutorMsgTxCT = new CustomEventMessage(scriptExecutorMsgCT, true); // 双向:scripting <-> content - -// 初始化日志组件(写入 service_worker/logger) -const loggerCore = new LoggerCore({ - writer: new MessageWriter(senderToExt, "serviceWorker/logger"), - labels: { env: "scripting" }, -}); - -// scripting 对页面投递消息的通道(token 在握手后设置) -const scriptingMessaging = createPageMessaging(""); // 对 inject / content 的 client 发出消息 - -// 将消息从 scripting 投递到 inject/content 的工具(基于自定义事件) -const messageDeliveryToPage = new MessageDelivery(); - -// service_worker 客户端 -const client = new RuntimeClient(senderToExt); - -loggerCore.logger().debug("scripting start"); - -// ================================ -// 工具函数:基础检查与小封装 -// ================================ - -// 确保 scripting messaging 已就绪 -const requireScriptingToken = (): string => { - if (!scriptingMessaging.et) { - // scriptingMessaging 尚未准备好或已被销毁 - throw new Error("scriptingMessaging is not ready or destroyed"); - } - return scriptingMessaging.et; -}; - -const setupDeliveryChannel = () => { - const token = requireScriptingToken(); - messageDeliveryToPage.setup(`evt_${token}_deliveryMessage`); -}; - -// ================================ -// Server 构建与 service_worker 转发 -// ================================ - -type GmApiPayload = { api: string; params: any; uuid: string }; - -const handleRuntimeGmApi = ( - senderToInject: CustomEventMessage, - senderToContent: CustomEventMessage, - data: GmApiPayload -) => { - // 拦截关注的 API,未命中则返回 false 交由默认转发处理 - switch (data.api) { - case "CAT_createBlobUrl": { - const file = data.params[0] as File; - const url = makeBlobURL({ blob: file, persistence: false }) as string; - return url; - } - case "CAT_fetchBlob": { - return fetch(data.params[0]).then((res) => res.blob()); - } - case "CAT_fetchDocument": { - const [url, isContent] = data.params; - return new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.responseType = "document"; - xhr.open("GET", url); - xhr.onload = () => { - // 根据来源选择不同的消息桥(content / inject) - const msg = isContent ? senderToContent : senderToInject; - const nodeId = msg.sendRelatedTarget(xhr.response); - resolve(nodeId); - }; - xhr.send(); - }); - } - case "GM_addElement": { - const [parentNodeId, tagName, tmpAttr, isContent] = data.params; - - // 根据来源选择不同的消息桥(content / inject) - const msg = isContent ? senderToContent : senderToInject; - - // 取回 parentNode(如果存在) - let parentNode: Node | undefined; - if (parentNodeId) { - parentNode = msg.getAndDelRelatedTarget(parentNodeId) as Node | undefined; - } - - // 创建元素并设置属性 - const el = document.createElement(tagName); - const attr = tmpAttr ? { ...tmpAttr } : {}; - let textContent = ""; - if (attr.textContent) { - textContent = attr.textContent; - delete attr.textContent; - } - for (const key of Object.keys(attr)) { - el.setAttribute(key, attr[key]); - } - if (textContent) el.textContent = textContent; - - // 优先挂到 parentNode,否则挂到 head/body/任意节点 - const node = parentNode || document.head || document.body || document.querySelector("*"); - node.appendChild(el); - - // 返回节点引用 id,供另一侧再取回 - const nodeId = msg.sendRelatedTarget(el); - return nodeId; - } - case "GM_log": - // 拦截 GM_log:直接打印到控制台(某些页面可能劫持 console.log) - switch (data.params.length) { - case 1: - console.log(data.params[0]); - break; - case 2: - console.log("[" + data.params[1] + "]", data.params[0]); - break; - case 3: - console.log("[" + data.params[1] + "]", data.params[0], data.params[2]); - break; - } - break; - } - return false; -}; - -const prepareServer = ( - server: Server, - senderToExt: MessageSend, - senderToInject: CustomEventMessage, - senderToContent: CustomEventMessage -) => { - // service_worker 下发日志:统一打印 - server.on("logger", (data: Logger) => { - LoggerCore.logger().log(data.level, data.message, data.label); - }); - - // 将 inject/content 的请求转发到 service_worker - forwardMessage("serviceWorker", "script/isInstalled", server, senderToExt); - - // runtime/gmApi:对部分 API 做拦截处理 - forwardMessage("serviceWorker", "runtime/gmApi", server, senderToExt, (data: GmApiPayload) => { - return handleRuntimeGmApi(senderToInject, senderToContent, data); +import type { Message } from "@Packages/message/types"; +import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { ScriptEnvTag } from "@Packages/message/consts"; +import { Server } from "@Packages/message/server"; +import ScriptingRuntime from "./app/service/content/scripting"; +import { negotiateEventFlag } from "@Packages/message/common"; + +const messageFlag = process.env.SC_RANDOM_KEY!; + +// 将初始化流程完成后,将EventFlag通知到其他环境 +negotiateEventFlag(messageFlag, 2, (eventFlag) => { + // 建立与service_worker页面的连接 + const extMsgComm: Message = new ExtensionMessage(false); + // 初始化日志组件 + const logger = new LoggerCore({ + writer: new MessageWriter(extMsgComm, "serviceWorker/logger"), + labels: { env: "scripting" }, }); -}; - -// ================================ -// 握手:MessageFlag 与 injectFlagEvt 协商 -// ================================ - -/** - * 握手目标: - * - scripting 生成 injectFlagEvt(随机) - * - content/inject 通过 executorEnvReadyKey 收到 injectFlagEvt,并回发 emitterKey - * - 当 scripting 收到 inject+content 都 ready 后,才建立 server + delivery 通道 - */ -const onMessageFlagReceived = (MessageFlag: string) => { - const executorEnvReadyKey = uuidv5("scriptcat-executor-ready", MessageFlag); - - // 由 scripting 随机生成,用于 scripting <-> inject/content 的消息通道 token - const injectFlagEvt = randomMessageFlag(); - - // readyFlag 位运算:inject=1,content=2,凑齐 3 表示都 ready. ready 后设为 4 避免再触发 - let readyFlag = 0; - - const finalizeWhenReady = () => { - if (readyFlag === 3) { - readyFlag = 4; // 确保单次调用限制 - // 统一设置 token - scriptingMessaging.setMessageTag(injectFlagEvt); - scriptExecutorMsgIT.setMessageTag(`${injectFlagEvt}_${ScriptEnvTag.inject}`); - scriptExecutorMsgCT.setMessageTag(`${injectFlagEvt}_${ScriptEnvTag.content}`); + logger.logger().debug("scripting start"); - // 绑定 receiver(允许 inject/content 发消息给 scripting) - scriptExecutorMsgTxIT.bindReceiver(); - scriptExecutorMsgTxCT.bindReceiver(); + const contentMsg = new CustomEventMessage(`${eventFlag}${ScriptEnvTag.content}`, true); + const injectMsg = new CustomEventMessage(`${eventFlag}${ScriptEnvTag.inject}`, true); - // 建立 server:inject/content -> scripting 通道 - const server = new Server("scripting", [scriptExecutorMsgTxIT, scriptExecutorMsgTxCT]); - prepareServer(server, senderToExt, scriptExecutorMsgTxIT, scriptExecutorMsgTxCT); + const server = new Server("scripting", [contentMsg, injectMsg]); - // 建立向页面投递消息的 delivery 通道 - setupDeliveryChannel(); - } - }; - - // 接收 inject/content 的 ready 回执 - pageAddEventListener(`${injectFlagEvt}`, (ev) => { - if (!(ev instanceof CustomEvent)) return; - - const key = `emitterKeyFor${injectFlagEvt}`; - let value = ev.detail?.[key]; - if (!value) return; - - if (value !== ScriptEnvType.content) value = ScriptEnvType.inject; // 使 value 必定为 1 或 2 - readyFlag |= value; - finalizeWhenReady(); - }); - - // 向 inject/content 广播 injectFlagEvt(让它们知道后续用哪个 token 通信) - const submitTarget = () => { - return pageDispatchCustomEvent(executorEnvReadyKey, { injectFlagEvt }); - }; - - // 处理“scripting 早于 content/inject 执行”的场景: - // content/inject 会先发一个 executorEnvReadyKey(detail 为空)来探测 scripting 是否在 - pageAddEventListener(executorEnvReadyKey, (ev) => { - if (readyFlag < 3 && ev instanceof CustomEvent && !ev.detail) { - submitTarget(); - } - }); - - // 处理“scripting 晚于 content/inject 执行”的场景: - // scripting 启动后主动广播一次 executorEnvReadyKey,content/inject 立刻能收到 injectFlagEvt - submitTarget(); -}; - -// ================================ -// 来自 service_worker 的投递:storage 广播(类似 UDP) -// ================================ - -// 接收 service_worker 的 chrome.storage.local 值改变通知 (一对多广播) -// 类似 UDP 原理,service_worker 不会有任何「等待处理」 -// 由于 changes 会包括新旧值 (Chrome: JSON serialization, Firefox: Structured Clone) -// 因此需要注意资讯量不要过大导致 onChanged 的触发过慢 -chrome.storage.local.onChanged.addListener((changes) => { - if (changes["localStorage:scriptInjectMessageFlag"]?.newValue) { - messageDeliveryToPage.dispatch({ - tag: "localStorage:scriptInjectMessageFlag", - value: changes["localStorage:scriptInjectMessageFlag"]?.newValue, - }); - } - if (changes["valueUpdateDelivery"]?.newValue) { - messageDeliveryToPage.dispatch({ - tag: "valueUpdateDelivery", - value: changes["valueUpdateDelivery"]?.newValue, - }); - } -}); - -// ================================ -// 来自 service_worker 的投递:runtime 一对一消息(类似 TCP) -// ================================ - -// 接收 service_worker 的 chrome.tabs.sendMessage (一对一消息) -// 类似 TCP 原理,service_worker 有「等待处理」 -// 由于 message 会包括值 (Chrome: JSON serialization, Firefox: Structured Clone) -// 因此需要注意资讯量不要过大导致 等待处理 时间过长 -chrome.runtime.onMessage.addListener((message, _sender) => { - if (!message) return; - const { action, data } = message; - messageDeliveryToPage.dispatch({ - tag: action, - value: data, - }); -}); - -// ================================ -// 启动流程 -// ================================ - -// 1) scripting 直接读取 MessageFlag,并开始握手 -onMessageFlagReceived(MessageFlag); - -// 2) 向 service_worker 请求脚本列表及环境信息,并下发给 inject/content -// 向service_worker请求脚本列表及环境信息 -// - 以 ExtensionMessage 形式 从 scripting 发送到 service_worker 再以 Promise 形式取回 service_worker 结果 -client.pageLoad().then((o) => { - if (!o.ok) return; - - // 记录 uuid 来源:inject=1,content=2 - for (const entry of o.injectScriptList) { - uuids.set(entry.uuid, ScriptEnvType.inject); - } - for (const entry of o.contentScriptList) { - uuids.set(entry.uuid, ScriptEnvType.content); - } - // 一次性广播给 inject 和 content - messageDeliveryToPage.dispatch({ - tag: "pageLoad", - value: o, - }); + // Opera中没有chrome.runtime.onConnect,并且content也不需要chrome.runtime.onConnect + // 所以不需要处理连接,设置为false + const extServer = new Server("scripting", extMsgComm, false); + // scriptExecutor的消息接口 + // 初始化运行环境 + const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg); + runtime.init(); + // 页面加载,注入脚本 + runtime.pageLoad(); }); diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index a4972e4f7..a992f3f32 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -227,9 +227,3 @@ vi.stubGlobal("define", "特殊关键字不能穿透沙盒"); if (!URL.createObjectURL) URL.createObjectURL = undefined; //@ts-expect-error if (!URL.revokeObjectURL) URL.revokeObjectURL = undefined; - -const simulatedEventTarget = Object.create(EventTarget.prototype); -simulatedEventTarget.addEventListener = vi.fn(); -simulatedEventTarget.removeEventListener = vi.fn(); -simulatedEventTarget.dispatchEvent = vi.fn(); -vi.stubGlobal("simulatedEventTarget", simulatedEventTarget);