一个旨在帮助开发者快速构建功能强大、可维护的现代油猴脚本的框架。它提供模块化架构、自动化的UI设置面板、响应式生命周期管理、高性能DOM调度器与网络请求拦截器等核心功能,让您专注于实现创意,而非繁琐的底层细节。
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/545792/1908492/Caliberjs%20Framework%20Library.js
// ==UserScript==
// @name Caliber.js Framework Library
// @namespace You Boy
// @version 1.2.0
// @description 一个旨在帮助开发者快速构建功能强大、可维护的现代油猴脚本的框架。它提供模块化架构、自动化的UI设置面板、响应式生命周期管理、高性能DOM调度器与网络请求拦截器等核心功能,让您专注于实现创意,而非繁琐的底层细节。
// @author You Boy
// @license MIT
// @grant none
// ==/UserScript==
((window) => {
"use strict";
const Caliber = (() => {
"use strict";
// #region --- 类型定义 (Type Definitions) ---
/**
* @typedef {object} MatchResult URL 匹配结果结构体
* @property {Record<string, string|undefined>} params - 从路径中提取的动态命名参数 (如 :id, :userId 等)。
* @property {Record<string, string|string[]>} query - 解析后的 URL Query 查询参数。
*/
/**
* @typedef {object} DomProcessorOptions 高性能DOM调度器监听配置选项
* @property {boolean} [add=true] - 是否监听节点的添加插入。
* @property {boolean} [attributes=false] - 是否监听节点属性的变化。
* @property {string[]} [attributeFilter] - (可选) 当 attributes 为 true 时,限制只监听指定的属性名列表。
* @property {HTMLElement|string} [root] - (可选) 监听的根节点或其 CSS 选择器,限定匹配范围。
* @property {boolean} [processExisting=false] - (可选) 是否在注册任务时立即主动处理页面中已存在的匹配节点。
*/
/**
* @typedef {object} DomBatchProcessorInstance 高性能DOM批量处理调度器实例
* @property {(selector: string, callback: (node: HTMLElement) => void, options?: DomProcessorOptions) => Symbol} register - 注册一个DOM处理任务,返回一个唯一的任务ID。
* @property {(taskId: Symbol) => void} unregister - 根据任务ID注销一个DOM处理任务。
*/
/**
* @typedef {object} ConfigManagerInstance 配置管理器实例
* @property {() => object} getConfig - 获取当前已加载的配置对象。
* @property {(path: string, value: any) => Promise<void>} updateAndSave - 按路径更新配置项并自动保存。
* @property {() => Promise<void>} save - 手动将当前配置保存到存储中。
*/
/**
* @typedef {object} ModuleManagerInstance 模块管理器实例
* @property {(ModuleClass: typeof Module) => void} register - 注册一个模块类。
* @property {() => Module[]} getAllRegisteredModules - 获取所有已注册模块的元数据,主要用于UI生成。
* @property {(id: string) => Promise<void>} requestModuleReset - 异步请求对指定模块执行热重置。
*/
/**
* @typedef {object} ModuleControlFacadeInstance 模块控制门面实例
* @property {() => Promise<void>} requestReset - 请求模块管理器对当前模块执行一次完整的无竞态“停用-启用”重置循环。
*/
/**
* @typedef {object} ModuleConfigItem 模块单项配置结构体
* @property {*} value - 该配置项的默认值。
* @property {string} [label] - 在设置面板中显示的名称(未提供则使用键名)。
* @property {"boolean"|"string"|"number"|"select"|"color"|"textarea"} [type] - 控件类型。
* @property {string} [description] - 配置项的详细描述文本。
* @property {Array<{label: string, value: *}>} [options] - 当 type 为 "select" 时的下拉候选项列表。
* @property {Record<string, *>} [inputProps] - 传递给原生 input/select/textarea 标签的额外 HTML 属性(如 min, max, step, placeholder, rows 等)。
* @property {"top"|"bottom"|"both"} [divider] - (可选) 是否在该项上方/下方添加视觉分割线。
* @property {1|2|3} [indentLevel] - (可选) 树状视觉层级缩进(1~3 级)。
*/
/**
* @typedef {object} LoggerInstance 日志记录器实例
* @property {(message: any, ...args: any[]) => void} log - 输出一条标准日志。
* @property {(message: any, ...args: any[]) => void} warn - 输出一条警告日志。
* @property {(message: any, ...args: any[]) => void} error - 输出一条错误日志。
* @property {(tag: string, styleOptions?: object) => LoggerInstance} createTaggedLogger - 创建一个带有自定义标签和样式的子日志记录器。
*/
/**
* @typedef {object} EventBusInstance 全局事件总线实例
* @property {(eventName: string, callback: (data: any) => void) => void} on - 订阅一个事件。
* @property {(eventName: string, callback: (data: any) => void) => void} off - 取消订阅一个事件。
* @property {(eventName: string, data?: any) => void} emit - 发布一个事件。
*/
/**
* @typedef {object} StorageAdapter 存储服务适配器
* @property {() => Promise<object>} get - 异步获取存储的配置对象。
* @property {(value: object) => Promise<void>} set - 异步将配置对象写入存储。
*/
/**
* @typedef {object} StyleAdapter 样式服务适配器
* @property {(cssString: string, id: string) => void} add - 注入一段CSS样式,并关联一个唯一ID。
* @property {(id: string) => void} remove - 根据ID移除之前注入的样式。
*/
/**
* @typedef {object} CommandAdapter 菜单命令服务适配器
* @property {(name: string, callback: () => void) => void} register - 注册一个菜单命令。
*/
/**
* @typedef {string} FetchInterceptorRequestString
*
* 定义当一个网络请求被成功匹配时,在**发起实际请求之前**要执行的修改逻辑。
* 这段代码在宿主页面的上下文中运行,拥有访问`window`对象的全部能力,但必须是一个无闭包依赖的纯字符串。
*/
/**
* @typedef {object} FetchInterceptorBuilder 网络请求拦截器构建器
* @property {(handlerString: FetchInterceptorRequestString) => FetchInterceptorBuilder} onRequest - 定义请求被拦截时要执行的逻辑。
* @property {(callback: (responseData: any) => void) => FetchInterceptorBuilder} onResponse - 定义在沙箱中处理响应数据的回调函数。
* @property {(id: string|string[]) => void} register - 最终确定并注册这个拦截器。
*/
/**
* @typedef {object} PageScopeExecutorInstance 页面作用域代码执行器服务实例
* @property {(codeString: string) => Promise<any>} execute - 在宿主页面环境中异步执行一段JS代码,并返回其可序列化的结果。
*/
/**
* @callback DomWatcherCallback
* @param {MutationRecord[]} mutations - DOM变化记录数组。
*/
/**
* @typedef {object} DomWatcherServiceInstance 统一DOM观察者服务实例
* @property {(callback: DomWatcherCallback) => Symbol} subscribe - 订阅DOM变化,返回一个唯一的订阅ID。
* @property {(id: Symbol) => void} unsubscribe - 根据订阅ID取消订阅。
*/
/**
* @callback ResponseCallback
* @param {any} responseData - 从注入脚本传回的、已解析的响应数据。
*/
/**
* @typedef {object} FrameworkUtils 框架提供的公共工具函数集合
* @property {(matchRule: string|RegExp|Array<string|RegExp>|null|undefined, hostWindow?: Window) => MatchResult|false} checkMatch - 检查给定的匹配规则是否与当前页面URL匹配。
*/
/**
* @typedef {object} FetchInterceptorInstance 网络请求拦截器服务实例
* @property {(urlOrOptions: string|{url: string, method?: string, match?: string|RegExp|Array<string|RegExp>}) => FetchInterceptorBuilder} target - [推荐] 启动一个链式调用来构建拦截器。
* @property {(options: {targetUrl: string, method?: string, handler: string}) => string} createHook - (底层) 创建一个钩子函数字符串。
* @property {(path: string[]|string, hookFunctionString: string, awaitsResponse?: boolean) => void} addHook - (底层) 添加一个仅修改请求的钩子。
* @property {(path: string[]|string, hookFunctionString: string, responseCallback: ResponseCallback) => void} addHookWithResponse - (底层) 添加一个带响应回调的钩子。
* @property {(path: string[]|string) => void} removeHook - (底层/通用) 按ID移除一个已注册的拦截器。
* @property {() => void} destroy - 销毁拦截器并批量清除宿主 Hub 中的专属钩子。
*/
/**
* @typedef {object} DOMSanitizerInstance DOM净化与安全注入服务实例
* @property {(htmlString: string) => (TrustedHTML|string)} createTrustedHTML - 创建一个受信任的HTML对象(如果Trusted Types策略可用)。
* @property {(element: Element, htmlString: string) => void} setInnerHTML - 安全地设置一个元素的innerHTML。
* @property {(doc: Document, codeString: string) => void} injectScript - 安全地向文档注入一段JavaScript代码。
* @property {(doc: Document, cssString: string, id: string) => (HTMLStyleElement|null)} injectStyle - 安全地向文档注入一段CSS样式。
*/
/**
* @typedef {object} FrameworkServices 框架核心服务集合
* @property {DomBatchProcessorInstance} scheduler - 高性能DOM批量处理调度器。
* @property {FetchInterceptorInstance} interceptor - 网络请求拦截器。
* @property {DOMSanitizerInstance} sanitizer - DOM净化与安全注入服务。
* @property {PageScopeExecutorInstance} executor - 页面作用域代码执行器。
* @property {DomWatcherServiceInstance} [_internal_domWatcher] - (内部服务) 统一的DOM观察者。
* @property {FrameworkUtils} [utils] - 框架提供的公共工具函数集合。
*/
/**
* @typedef {object} CaliberServices 内核服务包
* @property {LoggerInstance} logger - 日志服务实例。
* @property {EventBusInstance} eventBus - 全局事件总线实例。
* @property {Window} hostWindow - 宿主环境的 window 对象 (通常是 unsafeWindow)。
* @property {Document} hostDocument - 宿主环境的 document 对象。
* @property {StorageAdapter} storage - 存储服务适配器 (e.g., GM.getValue/setValue)。
* @property {StyleAdapter} style - 样式服务适配器 (e.g., GM.addStyle)。
* @property {string} APP_NAME - 当前应用的名称。
* @property {ConfigManagerInstance} configManager - 配置管理器实例。
* @property {ModuleManagerInstance} moduleManager - 模块管理器实例。
* @property {ModuleControlFacadeInstance} [module] - (可选) 模块自身管理服务,仅在模块实例化时由ModuleManager动态注入。
* @property {FrameworkServices} framework - 框架核心服务集合。
*/
/**
* @typedef {object} AppKernelInstance 应用程序内核实例
* @property {() => Promise<void>} run - 启动应用内核。
* @property {(ModuleClass: typeof Module) => void} registerModule - 注册一个功能模块类。
* @property {() => Promise<void>} destroy - 全量无痕销毁内核。
*/
/**
* @typedef {object} SettingsPanelOptions 设置面板及触发按钮的自定义配置
* @property {boolean} [enabled=true] - 设置面板在首次启动时是否默认开启。
* @property {number} [bottom=50] - 触发按钮距离页面底部的像素值。
* @property {number} [right=0] - 触发按钮距离页面右侧的像素值。
*/
/**
* @typedef {object} AppOptions 应用创建参数选项
* @property {string} appName - 应用的名称。将用于日志前缀、UI标题和菜单项。
* @property {Array<typeof Module>} modules - 一个由模块类组成的数组。
* @property {object} services - 包含所有平台相关服务实现的对象。
* @property {StorageAdapter} services.storage - 存储服务适配器。
* @property {CommandAdapter} services.command - 菜单命令服务适配器。
* @property {Window} [services.hostWindow] - (可选) 宿主 window 对象。
* @property {Document} [services.hostDocument] - (可选) 宿主 document 对象。
* @property {StyleAdapter} [services.style] - (可选) 样式注入适配器。
* @property {boolean} [isDebug=false] - (可选) 是否开启调试与泄漏审计模式。
* @property {boolean} [settingsPanelEnabled=true] - (可选) 设置面板在首次启动时是否默认开启。
* @property {SettingsPanelOptions} [settingsPanel] - (可选) 设置面板位置与外观配置。
* @property {object} [framework] - (可选) 框架内部微调配置。
* @property {number} [framework.domProcessorBatchSize=20] - (可选) DOM批处理器在每个渲染帧中处理的最大任务数。
*/
/**
* @typedef {object} ModuleInterface 模块类的公共API与内部属性接口
* @property {string} id - 模块的唯一标识符,用于配置和管理。
* @property {string} name - 模块的显示名称,用于UI。
* @property {string} description - 模块的功能描述,用于UI。
* @property {Object.<string, (*|ModuleConfigItem)>} defaultConfig - 模块的默认配置表,支持传基本值或 ModuleConfigItem 对象。
* @property {string|RegExp|Array<string|RegExp>|null} [match=null] - (可选) 限制模块仅在匹配该规则的页面上运行。
* @property {object} [uiGuard] - (可选) 声明式UI守护配置。
* @property {string} uiGuard.target - UI组件应该被注入的目标父容器的选择器。
* @property {string} uiGuard.component - UI组件自身的选择器,用于检查其是否存在。
*
* @property {CaliberServices} _services - (底层) 内核注入的完整核心服务集合。
* @property {object} _config - 模块在总配置对象中的专属部分。
* @property {LoggerInstance} _logger - (快捷方式) 日志记录器。
* @property {EventBusInstance} _eventBus - (快捷方式) 事件总线。
* @property {Window} _hostWindow - (快捷方式) 宿主 window 对象。
* @property {Document} _hostDocument - (快捷方式) 宿主 document 对象。
* @property {DomBatchProcessorInstance} _scheduler - (快捷方式) 高性能DOM批量处理调度器。
* @property {FetchInterceptorInstance} _interceptor - (快捷方式) 网络请求拦截器。
* @property {DOMSanitizerInstance} _sanitizer - (快捷方式) DOM净化与安全注入服务。
* @property {ModuleControlFacadeInstance} [_module] - (快捷方式) 模块自身管理服务。
* @property {PageScopeExecutorInstance} _executor - (快捷方式) 页面作用域代码执行器。
* @property {FrameworkUtils} _utils - (快捷方式) 框架提供的公共工具函数集合。
*
* @property {(context: MatchResult) => (void|Promise<void>)} onEnable - 当模块被启用时调用。
* @property {() => (void|Promise<void>)} onDisable - 当模块被禁用时调用。必须在此处清理所有副作用(支持异步 Promise)。
* @property {(key: string, newValue: any, oldValue: any) => void} onConfigChange - 当模块的特定配置项发生变化时调用。
* @property {(context: MatchResult) => (void|Promise<void>)} [onNavigate] - 当模块处于激活状态且发生URL导航时调用(支持异步 Promise)。
*
* @property {(targetElement: HTMLElement) => void} [onRender] - (守护模式生命周期) 当UI需要被渲染或恢复时调用。
* @property {() => void} [onCleanup] - (守护模式生命周期) 当UI需要被清理时调用。
*/
// #endregion
/**
* @class Module - 功能模块的标准化基类
*
* 所有功能模块都应继承此类,以确保接口统一和生命周期管理。
* @implements {ModuleInterface}
*/
class Module {
id = "base-module";
name = "Base Module";
description = "";
defaultConfig = {};
match = null;
uiGuard = null;
_services;
_config;
_logger;
_eventBus;
_hostWindow;
_hostDocument;
_scheduler;
_interceptor;
_sanitizer;
_executor;
_utils;
_module;
/**
* @param {CaliberServices} services - 内核注入的核心服务。
* @param {object} moduleConfig - 该模块在总配置中的专属配置部分。
*/
constructor(services, moduleConfig) {
if (!services) {
return;
}
this._services = services;
this._config = moduleConfig;
this._logger = services.logger;
this._eventBus = services.eventBus;
this._hostWindow = services.hostWindow;
this._hostDocument = services.hostDocument;
this._utils = services.framework.utils;
this._module = services.module;
// framework内部工具快捷方式
this._scheduler = services.framework.scheduler;
this._interceptor = services.framework.interceptor;
this._sanitizer = services.framework.sanitizer;
this._executor = services.framework.executor;
}
/**
* 当模块被启用时调用。所有事件监听和DOM操作应在此处初始化。
* @param {{params: object, query: object}} context - 包含从URL解析出的命名参数 (`params`) 和查询参数 (`query`) 的对象。
*/
onEnable(context) {
this._logger.warn(
`Module '${this.id}' is missing the 'onEnable' implementation.`,
);
}
onDisable() {}
/**
* 当模块的特定配置项发生变化时调用。
* @param {string} key - 发生变化的配置键。
* @param {*} newValue - 新的配置值。
* @param {*} oldValue - 旧的配置值。
*/
onConfigChange(key, newValue, oldValue) {}
onRender(targetElement) {}
onCleanup() {}
onNavigate(context) {}
}
/**
* @class AppKernel - 应用程序内核
*
* 负责管理模块生命周期、配置、UI和所有核心服务。
*/
class AppKernel {
#services = {};
#moduleManager;
#configManager;
#uiManager;
#logger;
#auditor = null;
#uiGuardianService;
#safeAppName;
/**
* @param {object} injectedServices - 由 `createApp` 组装好的所有核心服务和配置。
* @param {Window} injectedServices.hostWindow - 宿主 window 对象。
* @param {Document} injectedServices.hostDocument - 宿主 document 对象。
* @param {EventBusInstance} injectedServices.eventBus - 全局事件总线。
* @param {LoggerInstance} injectedServices.logger - 主日志记录器。
* @param {StorageAdapter} injectedServices.storage - 存储服务适配器。
* @param {StyleAdapter} injectedServices.style - 样式服务适配器。
* @param {FrameworkServices} injectedServices.framework - 框架内部服务集合。
* @param {string} injectedServices.APP_NAME - 应用名称。
* @param {string} injectedServices.SAFE_APP_NAME - 安全的应用名称。
* @param {boolean} injectedServices.IS_DEBUG - 是否为调试模式。
* @param {object} injectedServices.initialConfig - 框架的初始配置。
*/
constructor(injectedServices) {
const {
// --- 运行时服务 ---
hostWindow,
hostDocument,
eventBus,
logger,
storage,
style,
framework,
APP_NAME,
SAFE_APP_NAME,
// --- 元数据/配置 ---
IS_DEBUG,
initialConfig,
} = injectedServices;
this.#logger = logger;
this.#safeAppName = SAFE_APP_NAME;
// 将“运行时服务”组装到内核的 #services 对象中
this.#services = {
hostWindow,
hostDocument,
eventBus,
logger,
storage,
style,
framework,
APP_NAME,
SAFE_APP_NAME,
};
// 使用元数据进行初始化
if (IS_DEBUG) {
this.#auditor = new ModuleAuditor(
this.#logger,
hostWindow,
SAFE_APP_NAME,
);
this.#auditor?.patchScheduler(framework.scheduler);
}
// 实例化核心服务
this.#uiGuardianService = new UIGuardianService(
this.#services,
framework._internal_domWatcher,
);
this.#configManager = new ConfigManager(storage, logger, initialConfig);
this.#moduleManager = new ModuleManager(
this.#services,
this.#auditor,
this.#uiGuardianService,
);
this.#uiManager = new UIManager(
this.#services,
this.#uiGuardianService,
);
// 将新创建的管理器添加回 #services 包,以便所有模块都能访问它们
this.#services.configManager = this.#configManager;
this.#services.moduleManager = this.#moduleManager;
this.#logger.log("Kernel constructed.");
this.#services.eventBus.on(
"command:toggle-settings-panel",
this.#handleToggleSettingsPanel,
);
this.#services.eventBus.on("config-updated", this.#onConfigUpdated);
}
#handleToggleSettingsPanel = async () => {
const currentConfig = this.#configManager.getConfig();
if (!currentConfig) {
this.#logger.error(
"Cannot toggle settings panel: config not loaded yet.",
);
return;
}
const newState = !currentConfig.settingsPanel.enabled;
await this.#configManager.updateAndSave(
"settingsPanel.enabled",
newState,
);
this.#services.eventBus.emit("config-updated", {
path: "settingsPanel.enabled",
value: newState,
newConfig: this.#configManager.getConfig(),
});
};
#onConfigUpdated = (detail) => {
if (detail.path === "settingsPanel.enabled") {
this.#logger.log(
`Settings Panel visibility changed to: ${detail.value}`,
);
if (detail.value) {
this.#uiGuardianService.register(this.#uiManager);
} else {
this.#uiGuardianService.unregister(this.#uiManager);
}
}
};
/**
* 启动应用。这是整个应用逻辑的入口点。
*/
async run() {
this.#logger.log("Kernel is running...");
const moduleDefaultConfigs = this.#moduleManager.getAllDefaultConfigs();
const finalConfig =
await this.#configManager.loadAndGetConfig(moduleDefaultConfigs);
this.#moduleManager.initializeActiveModules(finalConfig);
this.#uiManager.init(finalConfig);
this.#logger.log("Kernel run sequence complete.");
}
/**
* 注册一个功能模块类。
* @param {typeof Module} ModuleClass - 要注册的模块类 (注意是类本身,不是实例)。
*/
registerModule(ModuleClass) {
this.#moduleManager.register(ModuleClass);
}
/**
* 销毁当前内核实例,停用所有激活模块、清理网络 Hooks、移除 UI 守护、解绑事件监听并释放全局 instanceKey。
*/
async destroy() {
this.#logger.log("Kernel is destroying...");
this.#services.eventBus.emit("kernel:destroy");
await this.#moduleManager.destroy();
if (this.#services.framework?.interceptor?.destroy) {
this.#services.framework.interceptor.destroy();
}
this.#uiGuardianService.unregister(this.#uiManager);
this.#services.eventBus.off(
"command:toggle-settings-panel",
this.#handleToggleSettingsPanel,
);
this.#services.eventBus.off("config-updated", this.#onConfigUpdated);
const instanceKey = `CALIBER_INSTANCE_${this.#safeAppName}`;
if (
this.#services.hostWindow &&
this.#services.hostWindow[instanceKey]
) {
delete this.#services.hostWindow[instanceKey];
}
this.#logger.log("Kernel destroyed successfully.");
}
}
/**
* @class ConfigManager - 配置管理器
*
* 负责加载、合并、保存配置。
*/
class ConfigManager {
#storage;
#logger;
#config;
#initialConfig;
constructor(storage, logger, initialConfig) {
this.#storage = storage;
this.#logger = logger;
this.#initialConfig = initialConfig;
}
/**
* 加载、合并配置,并返回最终结果。
* @param {object} moduleDefaultConfigs - 所有模块的默认配置集合。
* @returns {Promise<object>} 最终的运行时配置。
*/
async loadAndGetConfig(moduleDefaultConfigs) {
const userConfig = await this.#storage.get();
const baseConfig = { ...this.#initialConfig, ...moduleDefaultConfigs };
let mergedConfig = this.#deepMerge(baseConfig, userConfig);
this.#config = this.#prune(mergedConfig, baseConfig);
this.#logger.log(
"Configuration loaded, merged, and pruned:",
this.#config,
);
return this.#config;
}
getConfig() {
return this.#config;
}
/**
* 通过路径更新配置树中的一个值,并触发保存。
* @param {string} path - 要更新的配置路径,例如 'modules.themeSwitcher.theme'
* @param {*} value - 新的配置值
*/
async updateAndSave(path, value) {
this.#set(this.#config, path, value);
await this.save();
}
/**
* 将当前配置保存到存储中。
*/
async save() {
await this.#storage.set(this.#config);
this.#logger.log("Configuration saved.");
}
#deepMerge(target, source) {
const output = { ...target };
if (this.#isObject(target) && this.#isObject(source)) {
for (const key in source) {
const targetValue = target[key];
const sourceValue = source[key];
if (this.#isObject(targetValue) && this.#isObject(sourceValue)) {
output[key] = this.#deepMerge(targetValue, sourceValue);
} else if (
this.#isObject(targetValue) &&
!this.#isObject(sourceValue)
) {
continue;
} else {
output[key] = sourceValue;
}
}
}
return output;
}
#isObject = (item) =>
item && typeof item === "object" && !Array.isArray(item);
#set = (obj, path, value) => {
const keys = path.split(".");
const lastKey = keys.pop();
const finalObj = keys.reduce((o, k) => (o[k] = o[k] || {}), obj);
finalObj[lastKey] = value;
};
/**
* 以 template 为模板,递归地净化 source 对象。
* 1. 移除所有不存在于 template 中的键。
* 2. 检查值的类型,如果 source 中的值的类型与 template 不匹配,则强制回退到 template 的默认值。
* 这是框架数据自愈能力的核心。
* @param {object} source - 要被净化的对象 (例如,合并后的配置)。
* @param {object} template - 权威的结构模板 (例如,默认基础配置)。
* @returns {object} 净化后的新对象。
* @private
*/
#prune(source, template) {
const prunedSource = {};
for (const key in template) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const templateValue = template[key];
const sourceType = typeof sourceValue;
const templateType = typeof templateValue;
// 核心净化逻辑:
// 1. 如果类型匹配且都是对象,则递归净化。
// 2. 如果类型匹配但不是对象,则接受用户的源值。
// 3. 如果类型不匹配,则无条件地丢弃用户的源值,回退到模板的默认值。
if (sourceType === templateType) {
if (
this.#isObject(sourceValue) &&
this.#isObject(templateValue)
) {
prunedSource[key] = this.#prune(sourceValue, templateValue);
} else {
prunedSource[key] = sourceValue; // 类型匹配,接受用户的值
}
} else {
this.#logger.warn(
`Configuration type mismatch for key '${key}'. User value (${sourceType}) discarded. Falling back to default (${templateType}).`,
);
prunedSource[key] = templateValue; // 类型不匹配,强制回退
}
} else {
// 如果用户的配置中缺少这个键,直接使用模板的默认值
prunedSource[key] = template[key];
}
}
// 遍历 source 中存在、但 template 中不存在的键,并将它们保留下来
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(template, key)) {
prunedSource[key] = source[key];
}
}
return prunedSource;
}
}
/**
* @class ModuleManager - 模块管理器
*
* 负责注册、实例化和管理所有模块的生命周期。
*/
class ModuleManager {
#services;
#registeredModuleClasses = new Map();
#activeModuleInstances = new Map();
#auditor = null;
#uiGuardian;
#moduleTransitionQueues = new Map();
constructor(services, auditor, uiGuardian) {
this.#services = services;
this.#auditor = auditor;
this.#uiGuardian = uiGuardian;
this.#services.eventBus.on("config-updated", this.#onConfigUpdated);
this.#services.eventBus.on("navigate", this.#onNavigate);
}
/**
* 串行排他执行器,彻底防止多次连续 reset 或与路由变化重叠造成的竞态
* @private
*/
async #runExclusive(id, task) {
const prevPromise =
this.#moduleTransitionQueues.get(id) || Promise.resolve();
const currentPromise = (async () => {
try {
await prevPromise;
} catch (e) {
/* 忽略前序任务异常,保证后续队列继续推进 */
}
return await task();
})();
this.#moduleTransitionQueues.set(id, currentPromise);
try {
return await currentPromise;
} finally {
if (this.#moduleTransitionQueues.get(id) === currentPromise) {
this.#moduleTransitionQueues.delete(id);
}
}
}
/**
* [公共API] 请求模块管理器重置(停用后重新启用)一个模块。
* 这是模块自我刷新的最终、最可靠的方式。
* @param {string} id - 要重置的模块ID。
*/
async requestModuleReset(id) {
if (!this.#registeredModuleClasses.has(id)) {
this.#services.logger.warn(
`Module reset request for '${id}' failed: module class not found.`,
);
return;
}
return this.#runExclusive(id, async () => {
this.#services.logger.log(`Reset initiated for module '${id}'.`);
await this.#disableModule(id);
const freshConfig = this.#services.configManager.getConfig();
if (freshConfig) {
await this.#revalidateModuleState(id, freshConfig);
}
this.#services.logger.log(
`Reset sequence completed for module '${id}'.`,
);
});
}
/**
* 注册一个模块类。
* @param {typeof Module} ModuleClass
*/
register(ModuleClass) {
if (typeof ModuleClass !== "function") {
this.#services.logger.warn(
`Attempted to register an invalid value. Expected a class.`,
ModuleClass,
);
return;
}
const tempInstance = new ModuleClass();
// 校验模块实例是否符合最基本的规范 (必须有 id 和 name)
const isValidId =
tempInstance.id &&
typeof tempInstance.id === "string" &&
tempInstance.id !== "base-module";
const hasValidName =
tempInstance.name && typeof tempInstance.name === "string";
if (!isValidId || !hasValidName) {
this.#services.logger.warn(
`Attempted to register an invalid module. It must have a valid 'id' and 'name' property.`,
tempInstance,
);
return;
}
// 检查 ID 是否重复
if (this.#registeredModuleClasses.has(tempInstance.id)) {
this.#services.logger.warn(
`Attempt to register module with duplicate ID: '${tempInstance.id}'. Skipping.`,
);
return;
}
this.#registeredModuleClasses.set(tempInstance.id, ModuleClass);
this.#services.logger.log(
`Module class '${tempInstance.name} (${tempInstance.id})' registered.`,
);
}
/**
* 根据最终配置,初始化所有应该被激活的模块。
* @param {object} config - 最终的运行时配置。
*/
initializeActiveModules(config) {
this.#services.logger.log("Initializing active modules...");
this.#onNavigate();
}
/**
* 销毁模块管理器,停用所有已激活模块并注销事件监听。
*/
async destroy() {
for (const id of [...this.#activeModuleInstances.keys()]) {
await this.#runExclusive(id, () => this.#disableModule(id));
}
this.#registeredModuleClasses.clear();
this.#moduleTransitionQueues.clear();
this.#services.eventBus.off("config-updated", this.#onConfigUpdated);
this.#services.eventBus.off("navigate", this.#onNavigate);
}
/**
* 遍历所有已注册的模块类,收集它们的默认配置。
* @returns {object} 一个包含所有模块默认配置的聚合对象。
*/
getAllDefaultConfigs() {
const modulesConfig = {};
for (const ModuleClass of this.#registeredModuleClasses.values()) {
const tempInstance = new ModuleClass();
const processedDefaultConfig = {};
for (const key in tempInstance.defaultConfig) {
const item = tempInstance.defaultConfig[key];
if (typeof item === "object" && item !== null && "value" in item) {
processedDefaultConfig[key] = item.value;
} else {
processedDefaultConfig[key] = item;
}
}
const initialEnabledState = processedDefaultConfig.enabled === true;
delete processedDefaultConfig.enabled;
modulesConfig[tempInstance.id] = {
enabled: initialEnabledState,
...processedDefaultConfig,
};
}
return { modules: modulesConfig };
}
/**
* 返回所有已注册模块类的实例数组,用于UI生成。
* @returns {Module[]}
*/
getAllRegisteredModules() {
const modules = [];
for (const ModuleClass of this.#registeredModuleClasses.values()) {
const moduleInstance = new ModuleClass();
const clonedDefaultConfig = structuredClone(
moduleInstance.defaultConfig,
);
modules.push({
id: moduleInstance.id,
name: moduleInstance.name,
description: moduleInstance.description,
defaultConfig: clonedDefaultConfig,
});
}
return modules;
}
/**
* 当配置更新时被调用的核心响应函数
* @param {object} detail - 包含 { path, value, newConfig } 的事件数据
*/
#onConfigUpdated = async (detail) => {
const { path, value, newConfig } = detail;
const enabledMatch = path.match(/^modules\.([^.]+)\.enabled$/);
if (enabledMatch) {
const moduleId = enabledMatch[1];
await this.#runExclusive(moduleId, () =>
this.#revalidateModuleState(moduleId, newConfig),
);
return;
}
const optionMatch = path.match(/^modules\.([^.]+)\.(.+)$/);
if (optionMatch) {
const moduleId = optionMatch[1];
const key = optionMatch[2];
const moduleInstance = this.#activeModuleInstances.get(moduleId);
if (moduleInstance) {
const oldConfig = { ...moduleInstance._config };
moduleInstance._config[key] = value;
try {
moduleInstance.onConfigChange(key, value, oldConfig[key]);
} catch (e) {
this.#services.logger.error(
`Error during onConfigChange for module '${moduleId}'.`,
e,
);
}
}
}
};
/**
* 封装的启用模块的逻辑(支持异步 onEnable)
*/
async #enableModule(id, ModuleClass, config, matchContext) {
if (this.#activeModuleInstances.has(id)) return;
try {
const moduleConfig = config.modules[id];
const configManagerFacade = {
getConfig: () => moduleConfig,
updateAndSave: async (key, value) => {
const path = `modules.${id}.${key}`;
await this.#services.configManager.updateAndSave(path, value);
this.#services.eventBus.emit("config-updated", {
path,
value,
newConfig: this.#services.configManager.getConfig(),
});
},
};
const moduleFacade = {
requestReset: () => this.requestModuleReset(id),
};
const moduleServicesFacade = {
...this.#services,
configManager: configManagerFacade,
moduleManager: null,
module: moduleFacade,
};
const moduleInstance = new ModuleClass(
moduleServicesFacade,
moduleConfig,
);
this.#auditor?.auditStart(id);
try {
await moduleInstance.onEnable(matchContext);
} finally {
this.#auditor?.auditEnd();
}
this.#activeModuleInstances.set(id, moduleInstance);
if (moduleInstance.uiGuard) {
this.#uiGuardian.register(moduleInstance);
}
this.#services.logger.log(`Module '${id}' dynamically ENABLED.`);
} catch (e) {
this.#services.logger.error(
`Failed to dynamically enable module '${id}'.`,
e,
);
}
}
/**
* 封装的禁用模块的逻辑
*/
async #disableModule(id) {
const moduleInstance = this.#activeModuleInstances.get(id);
if (!moduleInstance) return;
this.#activeModuleInstances.delete(id);
try {
if (moduleInstance.uiGuard) {
this.#uiGuardian.unregister(moduleInstance);
}
this.#auditor?.auditStart(id);
try {
await moduleInstance.onDisable();
} finally {
this.#auditor?.auditEnd();
}
this.#auditor?.runChecks(id);
this.#services.logger.log(`Module '${id}' dynamically DISABLED.`);
} catch (e) {
this.#services.logger.error(
`Failed to complete full cleanup for module '${id}', but it has been successfully deactivated.`,
e,
);
}
}
/**
* 在SPA导航时触发,重新评估所有模块的match状态。
* @private
*/
#onNavigate = async () => {
this.#services.logger.log(
"Navigation detected, re-evaluating all module states...",
);
const currentConfig = this.#services.configManager.getConfig();
if (!currentConfig) return;
for (const id of this.#registeredModuleClasses.keys()) {
await this.#runExclusive(id, () =>
this.#revalidateModuleState(id, currentConfig),
);
}
};
/**
* 重新评估一个模块的最终状态(启用/禁用),并执行相应操作。
* 这是模块生命周期管理的唯一决策点。
* @param {string} id - 模块ID
* @param {object} config - 当前的全局配置
* @private
*/
async #revalidateModuleState(id, config) {
const ModuleClass = this.#registeredModuleClasses.get(id);
if (!ModuleClass) return;
const isEnabledInConfig = config.modules[id]?.enabled;
const tempInstance = new ModuleClass();
const matchResult = _CaliberInternals._checkMatch(
tempInstance.match,
this.#services.hostWindow,
);
const shouldBeActive = isEnabledInConfig && !!matchResult;
const isActiveNow = this.#activeModuleInstances.has(id);
if (shouldBeActive && !isActiveNow) {
await this.#enableModule(id, ModuleClass, config, matchResult);
} else if (!shouldBeActive && isActiveNow) {
await this.#disableModule(id);
} else if (shouldBeActive && isActiveNow) {
const moduleInstance = this.#activeModuleInstances.get(id);
if (
moduleInstance &&
typeof moduleInstance.onNavigate === "function"
) {
try {
this.#auditor?.auditStart(id);
await moduleInstance.onNavigate(matchResult);
} catch (e) {
this.#services.logger.error(
`Error during onNavigate for module '${id}'.`,
e,
);
} finally {
this.#auditor?.auditEnd();
}
}
}
}
}
/**
* @class UIManager - UI管理器
*
* 负责创建、管理和销毁UI组件,如下方的设置面板。
*/
class UIManager {
#appName = "CaliberApp";
#safeAppName = "default";
#tagName = "settings-panel";
#services;
#logger;
#hostDocument;
#hostWindow;
#panelElement = null;
#lastKnownConfig = null;
#sanitizer;
#uiGuardian;
// --- UI Guardian Contract ---
uiGuard = {
target: "html",
component: "settings-panel", // 构造函数中会被动态覆盖
};
constructor(services, uiGuardian) {
this.#services = services;
this.#uiGuardian = uiGuardian;
this.#logger = services.logger;
this.#hostDocument = services.hostDocument;
this.#hostWindow = services.hostWindow;
this.#appName = services.APP_NAME || this.#appName;
this.#safeAppName = services.SAFE_APP_NAME || "app";
this.#sanitizer = services.framework.sanitizer;
// 1. 采用 FNV-1a 稳定哈希算法生成绝对唯一的 Web Component 标签名,彻底杜绝命名碰撞
const cleanPrefix =
this.#appName
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.slice(0, 12) || "app";
const stableHash = _CaliberInternals._fnv1aHash(
`${this.#safeAppName}::${this.#appName}`,
);
this.#tagName = `caliber-${cleanPrefix}-${stableHash}`;
// 2. 动态更新 UI Guardian 的守护选择器
this.uiGuard = {
target: "html",
component: this.#tagName,
};
// 3. 注册带有动态唯一标签名的 Web Component
this.#defineSettingsPanelComponent(this.#sanitizer, this.#tagName);
}
/**
* 根据最终配置决定是否显示设置面板的触发按钮
* @param {object} finalConfig - 从Kernel传入的最终运行时配置
*/
init(finalConfig) {
this.#lastKnownConfig = finalConfig;
if (finalConfig.settingsPanel.enabled) {
this.#uiGuardian.register(this);
}
}
/**
* [Guardian Lifecycle] 当UI需要被渲染或恢复时调用。
*/
onRender(targetElement) {
if (this.#hostDocument.querySelector(this.uiGuard.component)) return;
// this.#logger.log('Guardian is rendering the settings panel trigger.');
// 使用动态的标签名创建元素
this.#panelElement = this.#hostDocument.createElement(this.#tagName);
const modules = this.#services.moduleManager.getAllRegisteredModules();
this.#panelElement.setData(
this.#appName,
modules,
this.#lastKnownConfig,
this.#services.configManager,
this.#services.eventBus,
this.#hostWindow,
);
targetElement.appendChild(this.#panelElement);
}
/**
* [Guardian Lifecycle] 当UI需要被清理时调用。
*/
onCleanup() {
const panel = this.#hostDocument.querySelector(this.uiGuard.component);
if (panel) {
panel.remove();
}
this.#panelElement = null;
// this.#logger.log('Settings panel trigger cleaned up by Guardian.');
}
/**
* 定义 Web Component
* @param {DOMSanitizerInstance} sanitizer
* @param {string} tagName - 动态的自定义元素标签名
*/
#defineSettingsPanelComponent(sanitizer, tagName) {
if (customElements.get(tagName)) return;
class SettingsPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._isOpen = false;
this._appName = "CaliberApp";
this._btnBottom = 50;
this._btnRight = 0;
this._modules = [];
this._config = {};
this._configManager = null;
this._eventBus = null;
this._boundOpenPanel = this.openPanel.bind(this);
this._boundClosePanel = this.closePanel.bind(this);
this._eventsBound = false;
}
// 外部数据注入接口
setData(
appName,
modules,
config,
configManager,
eventBus,
hostWindow,
) {
this._appName = appName || this._appName;
this._modules = modules;
this._config = config;
this._configManager = configManager;
this._eventBus = eventBus;
this._hostWindow = hostWindow;
if (config && config.settingsPanel) {
this._btnBottom =
typeof config.settingsPanel.bottom === "number"
? config.settingsPanel.bottom
: 50;
this._btnRight =
typeof config.settingsPanel.right === "number"
? config.settingsPanel.right
: 0;
}
this.render(); // 数据注入后重新渲染
}
connectedCallback() {
if (!this.shadowRoot.firstChild) {
this.render();
}
if (!this._eventsBound) {
this.#addEventListeners();
this._eventsBound = true;
}
this.#applyTheme();
}
/**
* 当组件从DOM中移除时被调用,这是清理内存的关键。
*/
disconnectedCallback() {
this.#removeEventListeners();
this._eventsBound = false;
}
/**
* 集中添加所有事件监听器。
* @private
*/
#addEventListeners() {
this.shadowRoot
.querySelector(".trigger-btn")
.addEventListener("click", this._boundOpenPanel);
this.shadowRoot
.querySelector(".overlay")
.addEventListener("click", this._boundClosePanel);
this.shadowRoot
.querySelector(".drawer-content")
.addEventListener("change", this.#handleInputChange);
}
/**
* 集中移除所有事件监听器,防止内存泄漏。
* @private
*/
#removeEventListeners() {
const triggerBtn = this.shadowRoot.querySelector(".trigger-btn");
if (triggerBtn)
triggerBtn.removeEventListener("click", this._boundOpenPanel);
const overlay = this.shadowRoot.querySelector(".overlay");
if (overlay)
overlay.removeEventListener("click", this._boundClosePanel);
const content = this.shadowRoot.querySelector(".drawer-content");
if (content)
content.removeEventListener("change", this.#handleInputChange);
}
/**
* input change 事件的统一处理函数。
* @private
*/
#handleInputChange = (e) => {
const target = e.target;
if (!target.dataset.configPath) return;
const path = target.dataset.configPath;
let value;
switch (target.type) {
case "checkbox":
value = target.checked;
break;
case "number":
value = Number(target.value);
break;
case "text":
case "select-one":
case "color":
default:
value = target.value;
break;
}
this.handleConfigChange(path, value);
};
openPanel = () => {
if (this._isOpen) return;
this.#applyTheme();
this._isOpen = true;
this.shadowRoot.querySelector(".drawer").classList.add("open");
this.shadowRoot.querySelector(".overlay").classList.add("open");
this.shadowRoot
.querySelector(".trigger-btn")
.classList.add("hidden");
};
closePanel = () => {
if (!this._isOpen) return;
this._isOpen = false;
this.shadowRoot.querySelector(".drawer").classList.remove("open");
this.shadowRoot.querySelector(".overlay").classList.remove("open");
this.shadowRoot
.querySelector(".trigger-btn")
.classList.remove("hidden");
};
handleConfigChange = async (path, value) => {
await this._configManager.updateAndSave(path, value);
// 通知所有模块配置已更新
this._eventBus.emit("config-updated", {
path,
value,
newConfig: this._configManager.getConfig(),
});
};
/**
* 检查系统颜色模式并为面板应用相应的主题。
* @private
*/
#applyTheme() {
const drawer = this.shadowRoot.querySelector(".drawer");
if (!drawer) return;
const isSystemDark = this._hostWindow.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
if (isSystemDark) {
drawer.dataset.theme = "dark";
} else {
drawer.dataset.theme = "light";
}
}
render() {
const isRightDocked = this._btnRight === 0;
const isBottomDocked = this._btnBottom === 0;
const rTopLeft = "12px";
const rTopRight = isRightDocked ? "0" : "12px";
const rBottomRight = isRightDocked || isBottomDocked ? "0" : "12px";
const rBottomLeft = isBottomDocked ? "0" : "12px";
const btnBorderRadius = `${rTopLeft} ${rTopRight} ${rBottomRight} ${rBottomLeft}`;
const styles = `
:host {
position: fixed;
bottom: 25px;
right: 25px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
/* --- 1. 触发按钮 --- */
.trigger-btn {
width: 42px;
height: 42px;
border-radius: ${btnBorderRadius};
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color 0.2s;
opacity: 1;
visibility: visible;
background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
position: fixed;
right: ${this._btnRight}px;
bottom: ${this._btnBottom}px;
}
.trigger-btn:hover {
transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.3);
}
.trigger-btn .icon {
font-size: 24px;
color: #1d1d1f;
}
.trigger-btn.hidden {
opacity: 0;
visibility: hidden;
transform: scale(0.8);
pointer-events: none;
}
/* --- 2. 遮罩层 --- */
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s;
cursor: pointer;
}
.overlay.open {
opacity: 1;
visibility: visible;
}
/* --- 3. 抽屉面板 --- */
.drawer {
position: fixed;
top: 0;
right: 0;
width: 360px;
max-width: 100vw;
height: 100%;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s, box-shadow 0.3s;
box-shadow: -4px 0 20px rgba(0,0,0,0.1);
--bg-primary: #f3f4f5;
--bg-secondary: #ffffff;
--bg-tertiary: #f7f8f9;
--bg-hover: #f7f8f9;
--bg-input: #f7f8f9;
--bg-input-focus: #ffffff;
--bg-switch: #e5e7eb;
--bg-switch-checked: #006ef4;
--text-primary: #14191e;
--text-secondary: #64696e;
--text-tertiary: #8c9196;
--text-placeholder: #b0b5b9;
--border-primary: #e5e7eb;
--border-secondary: #dadde0;
--border-focus: #006ef4;
--border-focus-shadow: rgba(0, 110, 244, 0.2);
--shadow-primary: -4px 0 20px rgba(0,0,0,0.1);
background-color: var(--bg-primary);
--border-focus-shadow: rgba(0, 110, 244, 0.2);
--select-arrow-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%2364696e' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
}
.drawer[data-theme="dark"] {
--bg-primary: #1c1c1e;
--bg-secondary: #2c2c2e;
--bg-tertiary: #3a3a3c;
--bg-hover: #3a3a3c;
--bg-input: #3a3a3c;
--bg-input-focus: #1c1c1e;
--bg-switch: #3a3a3c;
--bg-switch-checked: #0a84ff;
--text-primary: #f5f5f7;
--text-secondary: #aeaeb2;
--text-tertiary: #8e8e93;
--text-placeholder: #636366;
--border-primary: #3a3a3c;
--border-secondary: #545458;
--border-focus: #0a84ff;
--border-focus-shadow: rgba(10, 132, 255, 0.2);
--shadow-primary: -4px 0 20px rgba(0,0,0,0.3);
--border-focus-shadow: rgba(10, 132, 255, 0.2);
--select-arrow-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23aeaeb2' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
}
.drawer {
box-shadow: var(--shadow-primary);
}
.drawer.open {
transform: translateX(0);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
transition: background-color 0.3s, border-color 0.3s;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-secondary);
}
.drawer-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
transition: color 0.3s;
}
.drawer-content {
flex-grow: 1;
overflow-y: auto;
padding: 16px;
overscroll-behavior: contain;
}
/* --- 4. 表单与布局 --- */
.details-wrapper {
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
transition: background-color 0.3s, border-color 0.3s;
background-color: var(--bg-secondary);
border: 1px solid var(--border-primary);
}
summary {
list-style: none;
display: block;
cursor: pointer;
padding: 16px 20px;
transition: background-color 0.2s;
}
summary::-webkit-details-marker {
display: none;
}
summary:hover {
background-color: var(--bg-hover);
}
.details-wrapper[noconfig] > summary {
cursor: default;
}
.details-wrapper[noconfig] > summary {
cursor: default;
}
.details-wrapper[open][noconfig] > .sub-items-container,
.sub-items-container:empty {
display: none;
}
.details-wrapper[open]:not([noconfig]) > summary {
border-bottom: 1px solid var(--border-primary);
}
.summary-content {
display: flex;
align-items: center;
margin-bottom: 0;
}
.form-info {
margin-left: 16px;
padding-top: 0;
flex: 1;
}
.form-info h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
transition: color 0.3s;
color: var(--text-primary);
}
.form-info h4 span {
font-size: 12px;
margin-left: 8px;
transition: color 0.3s;
color: var(--text-tertiary);
}
.form-info p {
margin: 0;
font-size: 12px;
transition: color 0.3s;
color: var(--text-tertiary);
}
.sub-items-container {
padding: 16px 20px;
}
.sub-item {
--indent-unit: 12px;
--indent-marker-color: var(--border-secondary);
display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: 16px;
padding-left: 0;
}
.sub-item[data-divider="top"],
.sub-item[data-divider="both"] {
border-top: 1px solid var(--border-secondary);
margin-top: 16px;
padding-top: 16px;
}
.sub-item[data-divider="bottom"],
.sub-item[data-divider="both"] {
border-bottom: 1px solid var(--border-secondary);
margin-bottom: 16px;
padding-bottom: 16px;
}
.sub-item[data-indent-level] {
position: relative;
}
.sub-item[data-indent-level]::before {
content: '└─';
position: absolute;
top: 10px;
font-family: monospace;
color: var(--indent-marker-color);
font-size: 12px;
line-height: 1;
}
.sub-item[data-indent-level="1"] {
padding-left: calc(var(--indent-unit) * 1.5);
}
.sub-item[data-indent-level="1"]::before {
left: 0;
}
.sub-item[data-indent-level="2"] {
padding-left: calc(var(--indent-unit) * 2.5);
}
.sub-item[data-indent-level="2"]::before {
left: calc(var(--indent-unit) * 0.5);
}
.sub-item[data-indent-level="3"] {
padding-left: calc(var(--indent-unit) * 3.5);
}
.sub-item[data-indent-level="3"]::before {
left: calc(var(--indent-unit) * 1.5);
}
.sub-item:last-child {
margin-bottom: 0;
}
.sub-item-label {
font-size: 14px;
flex: 1;
transition: color 0.3s;
color: var(--text-secondary);
}
.sub-item-info {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.sub-item-desc {
font-size: 12px;
margin-top: 6px;
padding: 0;
transition: color 0.3s;
color: var(--text-tertiary);
p {
margin: 0;
line-height: 1.2;
}
}
/* --- 5. Switch 开关样式 --- */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
border-radius: 22px;
transition: .3s;
background-color: var(--bg-switch);
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
transition: .3s;
}
input:checked + .slider {
background-color: var(--bg-switch-checked);
}
input:checked + .slider:before {
transform: translateX(18px);
}
/* --- 6. Text/Number 输入框样式 --- */
.text-input {
appearance: none;
-webkit-appearance: none;
min-height: 30px;
width: 120px;
padding: 6px 10px;
border-radius: 6px;
font-size: 14px;
outline: none;
text-align: right;
box-sizing: border-box;
transition: all 0.2s ease-in-out;
border: 1px solid var(--border-secondary);
background-color: var(--bg-input);
color: var(--text-primary);
}
.text-input:hover {
border-color: var(--border-primary);
background-color: var(--bg-tertiary);
}
.text-input:focus {
border-color: var(--border-focus);
background-color: var(--bg-input-focus);
box-shadow: 0 0 0 3px var(--border-focus-shadow);
}
select.text-input {
text-align: left;
text-align-last: left;
padding-right: 30px; /* 为箭头留出空间 */
background-image: var(--select-arrow-svg);
background-repeat: no-repeat;
background-position: right 0.7rem center;
background-size: 0.9em 0.9em;
}
input[type="color"].text-input {
width: 50px;
min-height: 34px;
padding: 4px;
background-color: transparent;
}
input[type="color"].text-input::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"].text-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
input[type="color"].text-input::-moz-color-swatch {
border: none;
border-radius: 4px;
}
/* --- 6. Text/Number/Textarea 输入框样式 --- */
.sub-item-info.block-layout {
flex-direction: column;
align-items: stretch;
}
.sub-item-info.block-layout .sub-item-label {
margin-bottom: 8px;
}
textarea.text-input {
width: 100%;
min-height: 72px;
text-align: left;
resize: vertical;
font-family: inherit;
line-height: 1.4;
box-sizing: border-box;
}
/* --- 7. 其他辅助样式 --- */
.info-only { padding-bottom: 8px; margin-bottom: 0; }
.info-text { font-size: 12px; text-align: center; width: 100%; margin: 0; transition: color 0.3s; color: var(--text-tertiary); }
.no-sub-config-text { font-size: 12px; text-align: center; padding: 8px 0; color: var(--text-placeholder); }
.main-divider { display: none; }
`;
const triggerButtonHTML = `<button class="trigger-btn">⚙️</button>`;
const drawerHTML = `
<div class="overlay"></div>
<div class="drawer">
<div class="drawer-header">
<h2>${this._appName} 设置</h2>
</div>
<div class="drawer-content">
${this.generateFormHTML()}
</div>
</div>
`;
const fullHTML = `
<style>${styles}</style>
${triggerButtonHTML}
${drawerHTML}
`;
sanitizer.setInnerHTML(this.shadowRoot, fullHTML);
this.#syncUIWithConfig();
}
_generateInputPropsString(props) {
if (!props || typeof props !== "object") {
return "";
}
return Object.entries(props)
.map(
([key, val]) =>
`${key}="${String(val).replace(/"/g, """)}"`,
)
.join(" ");
}
generateFormHTML() {
let html = "";
html += `<div class="form-group info-only"><p class="info-text">所有设置修改后将自动保存,部分设置需刷新页面生效。</p></div>`;
for (const module of this._modules) {
const moduleConfig = this._config.modules[module.id];
if (!moduleConfig) continue;
const configKeys = Object.keys(module.defaultConfig);
const nonEnabledConfigKeys = configKeys.filter(
(key) => key !== "enabled",
);
const optionCount = nonEnabledConfigKeys.length;
html += `<details class="details-wrapper" ${
optionCount === 0 ? "noconfig" : ""
}>
<summary>`;
html += `<div class="form-group summary-content">
<label class="switch">
<input type="checkbox" data-config-path="modules.${
module.id
}.enabled" data-needs-reload="true">
<span class="slider"></span>
</label>
<div class="form-info">
<h4>${module.name}<span>${
optionCount === 0 ? "" : " ⚙️"
}</span></h4>
<p>${module.description}</p>
</div>
</div></summary>`;
html += `<div class="sub-items-container">`;
if (nonEnabledConfigKeys.length === 0) {
html += ``;
} else {
for (const key of nonEnabledConfigKeys) {
const configItem = module.defaultConfig[key];
const value = moduleConfig[key];
const path = `modules.${module.id}.${key}`;
let inputHTML = "";
let labelText, controlType, itemDescription, inputProps;
if (
typeof configItem === "object" &&
configItem !== null &&
"value" in configItem
) {
labelText = configItem.label || key;
controlType = configItem.type || "string";
itemDescription = configItem.description || "";
inputProps = this._generateInputPropsString(
configItem.inputProps,
);
} else {
labelText = key;
controlType = typeof configItem;
itemDescription = "";
inputProps = "";
}
switch (controlType) {
case "boolean":
inputHTML = `<label class="switch"><input type="checkbox" data-config-path="${path}" ${
value ? "checked" : ""
}><span class="slider"></span></label>`;
break;
case "number":
inputHTML = `<input type="number" class="text-input" data-config-path="${path}" value="${
value || 0
}" ${inputProps}>`;
break;
case "string":
inputHTML = `<input type="text" class="text-input" data-config-path="${path}" value="${
value || ""
}" ${inputProps}>`;
break;
case "select":
inputHTML = `<select class="text-input" data-config-path="${path}" ${inputProps}>`;
if (
configItem.options &&
Array.isArray(configItem.options)
) {
configItem.options.forEach((option) => {
const selected =
option.value === value ? "selected" : "";
inputHTML += `<option value="${
option.value
}" ${selected}>${
option.label || option.value
}</option>`;
});
}
inputHTML += `</select>`;
break;
case "color":
inputHTML = `<input type="color" class="text-input color-input" data-config-path="${path}" value="${value}" ${inputProps}>`;
break;
case "textarea":
const escapedVal = (
value !== undefined && value !== null
? String(value)
: ""
)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
inputHTML = `<textarea class="text-input textarea-input" data-config-path="${path}" ${inputProps}>${escapedVal}</textarea>`;
break;
default:
html += `<div class="form-group sub-item"><span class="sub-item-label">${labelText}</span><p style="color:red;">(不支持的配置类型: ${controlType},需确保包含label,value,type字段)</p></div>`;
continue; // 跳过不支持的类型
}
let dataAttrs = "";
if (
typeof configItem.divider === "string" &&
configItem.divider
) {
dataAttrs += ` data-divider="${configItem.divider}"`;
}
if (
typeof configItem.indentLevel === "number" &&
configItem.indentLevel > 0
) {
dataAttrs += ` data-indent-level="${configItem.indentLevel}"`;
}
const isBlockControl = controlType === "textarea";
html += `
<div class="form-group sub-item" ${dataAttrs.trim()}>
<div class="sub-item-info ${isBlockControl ? "block-layout" : ""}">
<label class="sub-item-label">${labelText}</label>
${inputHTML}
</div>
${
itemDescription
? `<div class="sub-item-desc"><p>${itemDescription}</p></div>`
: ""
}
</div>`;
}
}
html += `</div></details><hr class="main-divider"/>`;
}
if (html.endsWith('<hr class="module-divider"/>')) {
html = html.slice(0, -28);
}
return html;
}
/**
* 遍历所有带 data-config-path 的输入框,并根据 this._config 设置其状态。
* @private
*/
#syncUIWithConfig() {
this.shadowRoot
.querySelectorAll("[data-config-path]")
.forEach((input) => {
const path = input.dataset.configPath;
const keys = path.split(".");
let value = this._config;
for (const key of keys) {
if (value === undefined || value === null) break;
value = value[key];
}
if (value === undefined || value === null) return;
switch (input.type) {
case "checkbox":
input.checked = Boolean(value);
break;
case "number":
case "text":
case "color":
case "select-one":
case "textarea":
input.value = value;
break;
}
});
}
}
customElements.define(tagName, SettingsPanel);
}
}
/**
* @class DomWatcherService - (核心服务) 统一DOM观察者
*
* 负责全局的DOM变化监听,提供订阅机制。
*/
class DomWatcherService {
#observer = null;
#subscribers = new Map();
#isObserving = false;
#logger;
#hostDocument;
/**
* @param {LoggerInstance} logger - 用于日志输出的 logger 对象。
* @param {Document} hostDocument - 宿主 document 对象。
*/
constructor(logger, hostDocument) {
this.#logger = logger.createTaggedLogger("Watcher", {
backgroundColor: "#03A9F4",
});
this.#observer = new MutationObserver(this.#handleMutations);
this.#hostDocument = hostDocument;
}
/**
* MutationObserver 的唯一回调。
* 极度轻量,只负责将原始的 mutations 数组分发给所有订阅者。
* @param {MutationRecord[]} mutations - DOM变化记录数组。
* @private
*/
#handleMutations = (mutations) => {
for (const callback of this.#subscribers.values()) {
try {
callback(mutations);
} catch (e) {
this.#logger.error("Error in a DomWatcher subscriber callback:", e);
}
}
};
/**
* 订阅DOM变化。
* @param {(mutations: MutationRecord[]) => void} callback - 当DOM发生变化时要执行的回调函数。
* @returns {Symbol} 一个唯一的订阅ID,用于后续的取消订阅。
*/
subscribe(callback) {
const id = Symbol("watcher-subscription");
this.#subscribers.set(id, callback);
this.#logger.log(
`New subscription added. Total: ${this.#subscribers.size}.`,
);
// 如果这是第一个订阅者,则启动观察者。
if (!this.#isObserving && this.#subscribers.size > 0) {
const targetNode =
this.#hostDocument.documentElement || this.#hostDocument;
this.#observer.observe(targetNode, {
childList: true,
subtree: true,
attributes: true,
// 采用固定的、全量的监听配置。
// 过滤职责完全交由上层订阅者处理。
});
this.#isObserving = true;
this.#logger.log("Observer started due to first subscription.");
}
return id;
}
/**
* 根据订阅ID取消订阅。
* @param {Symbol} id - `subscribe` 方法返回的订阅ID。
*/
unsubscribe(id) {
if (this.#subscribers.delete(id)) {
this.#logger.log(
`Subscription removed. Total: ${this.#subscribers.size}.`,
);
// 如果这是最后一个订阅者,则停止观察者以节省资源。
if (this.#subscribers.size === 0 && this.#isObserving) {
this.#observer.disconnect();
this.#isObserving = false;
this.#logger.log(
"Observer stopped as no subscribers are left. Entering sleep mode.",
);
}
}
}
}
/**
* @class UIGuardianService - (核心服务) UI守护
*
* 负责监控和修复UI组件的状态,确保它们始终存在于预期的DOM位置。
*/
class UIGuardianService {
#services;
#watcher;
#logger;
#registeredModules = new Set();
#watcherSubscriptionId = null;
#isActive = false;
constructor(services, watcher) {
this.#services = services;
this.#watcher = watcher;
this.#logger = services.logger.createTaggedLogger("Guardian", {
backgroundColor: "#FF6F00",
});
}
register(moduleInstance) {
if (
!moduleInstance.uiGuard ||
typeof moduleInstance.onRender !== "function" ||
typeof moduleInstance.onCleanup !== "function"
) {
this.#logger.warn(
"Attempted to register an invalid object for UI guarding.",
moduleInstance,
);
return;
}
this.#registeredModules.add(moduleInstance);
this.#logger.log(
`Module '${moduleInstance.id || "UIManager"}' registered. Total: ${
this.#registeredModules.size
}.`,
);
this.#checkAndHeal(moduleInstance);
this.#startService();
}
unregister(moduleInstance) {
if (this.#registeredModules.delete(moduleInstance)) {
this.#logger.log(
`Module '${moduleInstance.id || "UIManager"}' unregistered. Total: ${
this.#registeredModules.size
}.`,
);
try {
moduleInstance.onCleanup();
} catch (e) {
this.#logger.error(
`Error during onCleanup for '${moduleInstance.id || "UIManager"}':`,
e,
);
}
if (this.#registeredModules.size === 0) {
this.#stopService();
}
}
}
#startService() {
if (this.#isActive) return;
this.#isActive = true;
this.#watcherSubscriptionId = this.#watcher.subscribe(
this.#onDomChange,
);
this.#logger.log("Guardian service started (Pure Watcher Mode).");
}
#stopService() {
if (!this.#isActive) return;
this.#isActive = false;
if (this.#watcherSubscriptionId) {
this.#watcher.unsubscribe(this.#watcherSubscriptionId);
this.#watcherSubscriptionId = null;
}
this.#logger.log("Guardian service stopped and entered sleep mode.");
}
#onDomChange = () => {
const rIC =
this.#services.hostWindow.requestIdleCallback ||
((cb) => setTimeout(cb, 100));
rIC(
() => {
if (!this.#isActive) return;
let healingPerformed = false;
for (const module of this.#registeredModules) {
if (this.#checkAndHeal(module)) {
healingPerformed = true;
}
}
if (healingPerformed) {
this.#logger.log(`Guardian performed UI health corrections.`);
}
},
{ timeout: 500 },
);
};
#checkAndHeal(module) {
try {
const target = this.#services.hostDocument.querySelector(
module.uiGuard.target,
);
if (!target) return false;
const componentExists = target.querySelector(
module.uiGuard.component,
);
if (!componentExists) {
try {
module.onCleanup();
} catch (e) {
/* pre-cleanup */
}
module.onRender(target);
return true;
}
} catch (e) {
this.#logger.error(
`Error during checkAndHeal for '${module.id || "UIManager"}':`,
e,
);
}
return false;
}
}
/**
* @class ModuleAuditor - (仅在Debug模式下激活) 模块审计员
*
* 负责监控模块的副作用(事件监听、定时器),并在模块禁用后报告任何未被清理的资源泄漏。
*/
class ModuleAuditor {
#logger;
#hostWindow;
#originalAddEventListener;
#originalRemoveEventListener;
#originalSetInterval;
#originalClearInterval;
#originalSchedulerRegister;
#originalSchedulerUnregister;
#activeModuleId = null;
#trackedResources = new Map();
#instanceNamespace;
constructor(logger, hostWindow, SAFE_APP_NAME) {
this.#logger = logger.createTaggedLogger("Auditor", {
backgroundColor: "#9C27B0",
});
this.#hostWindow = hostWindow;
this.#instanceNamespace = `__CALIBER_${SAFE_APP_NAME}`;
this.#patchGlobalApis();
this.#logger.warn(
"Module Auditor is active. Resource leakage will be reported.",
);
}
/**
* 在 scheduler 实例创建后,由 Kernel 调用,用于代理其方法。
* @param {DomBatchProcessorInstance} schedulerInstance
*/
patchScheduler(schedulerInstance) {
if (!schedulerInstance) return;
this.#originalSchedulerRegister = schedulerInstance.register;
this.#originalSchedulerUnregister = schedulerInstance.unregister;
const self = this;
schedulerInstance.register = function (selector, callback, options) {
const taskId = self.#originalSchedulerRegister.call(
this,
selector,
callback,
options,
);
if (self.#activeModuleId) {
const resources = self.#initializeTracking(self.#activeModuleId);
resources.schedulers.add(taskId);
}
return taskId;
};
schedulerInstance.unregister = function (taskId) {
for (const resources of self.#trackedResources.values()) {
if (resources.schedulers.has(taskId)) {
resources.schedulers.delete(taskId);
break;
}
}
return self.#originalSchedulerUnregister.call(this, taskId);
};
this.#logger.log("Scheduler has been patched for auditing.");
}
/**
* 在调用模块的 onEnable/onDisable 之前调用,设置审计上下文。
* @param {string} moduleId - 正在被审计的模块ID。
*/
auditStart(moduleId) {
this.#activeModuleId = moduleId;
}
/**
* 在调用模块的 onEnable/onDisable 之后调用,清除审计上下文。
*/
auditEnd() {
this.#activeModuleId = null;
}
/**
* 在模块被禁用后,运行泄漏检查。
* @param {string} moduleId - 已被禁用的模块ID。
*/
runChecks(moduleId) {
const resources = this.#trackedResources.get(moduleId);
if (!resources) return;
let leaksFound = 0;
// 检查四元组精准事件监听器泄漏
if (resources.events.size > 0) {
resources.events.forEach((records, target) => {
if (records.size > 0) {
leaksFound += records.size;
const types = [...new Set([...records].map((r) => r.type))];
this.#logger.error(
`LEAK DETECTED in module '${moduleId}': Event listener(s) for type(s) [${types.join(
", ",
)}] were NOT removed from element:`,
target,
);
}
});
}
// 检查定时器泄漏
if (resources.intervals.size > 0) {
leaksFound += resources.intervals.size;
resources.intervals.forEach((id) => {
this.#logger.error(
`LEAK DETECTED in module '${moduleId}': A setInterval (ID: ${id}) was NOT cleared.`,
);
});
}
// 检查调度器任务泄漏
if (resources.schedulers.size > 0) {
leaksFound += resources.schedulers.size;
resources.schedulers.forEach((id) => {
this.#logger.error(
`LEAK DETECTED in module '${moduleId}': A scheduler task (ID: ${id.toString()}) was NOT unregistered.`,
);
});
}
if (leaksFound === 0) {
this.#logger.log(
`Module '${moduleId}' passed audit. All tracked resources were cleaned up.`,
);
}
this.#trackedResources.delete(moduleId);
}
#initializeTracking(moduleId) {
if (!this.#trackedResources.has(moduleId)) {
this.#trackedResources.set(moduleId, {
events: new Map(), // Target -> Set<{ type, listener, useCapture }>
intervals: new Set(),
schedulers: new Set(),
});
}
return this.#trackedResources.get(moduleId);
}
#patchGlobalApis() {
this.#originalAddEventListener = EventTarget.prototype.addEventListener;
this.#originalRemoveEventListener =
EventTarget.prototype.removeEventListener;
this.#originalSetInterval = this.#hostWindow.setInterval;
this.#originalClearInterval = this.#hostWindow.clearInterval;
const self = this;
// --- Patch addEventListener (精准四元组追踪) ---
EventTarget.prototype.addEventListener = function (
type,
listener,
options,
) {
// 精准过滤当前实例内部通信事件,防止跨实例与业务事件误放行
const isInternalEvent =
this === self.#hostWindow.document &&
typeof type === "string" &&
(type.startsWith(self.#instanceNamespace) ||
type.startsWith(
`__CALIBER_PAGE_EXECUTOR_${self.#instanceNamespace.slice(10)}`,
));
if (isInternalEvent) {
return self.#originalAddEventListener.call(
this,
type,
listener,
options,
);
}
if (self.#activeModuleId && typeof listener === "function") {
const resources = self.#initializeTracking(self.#activeModuleId);
if (!resources.events.has(this)) {
resources.events.set(this, new Set());
}
const useCapture =
typeof options === "boolean"
? options
: Boolean(options && options.capture);
resources.events.get(this).add({ type, listener, useCapture });
}
return self.#originalAddEventListener.call(
this,
type,
listener,
options,
);
};
// --- Patch removeEventListener (精准匹配 Target + Type + Listener + Phase) ---
EventTarget.prototype.removeEventListener = function (
type,
listener,
options,
) {
const useCapture =
typeof options === "boolean"
? options
: Boolean(options && options.capture);
for (const resources of self.#trackedResources.values()) {
if (resources.events.has(this)) {
const records = resources.events.get(this);
for (const record of records) {
if (
record.type === type &&
record.listener === listener &&
record.useCapture === useCapture
) {
records.delete(record);
break;
}
}
if (records.size === 0) {
resources.events.delete(this);
}
}
}
return self.#originalRemoveEventListener.call(
this,
type,
listener,
options,
);
};
// --- Patch setInterval ---
this.#hostWindow.setInterval = function (handler, timeout) {
const intervalId = self.#originalSetInterval.call(
self.#hostWindow,
handler,
timeout,
);
if (self.#activeModuleId) {
const resources = self.#initializeTracking(self.#activeModuleId);
resources.intervals.add(intervalId);
}
return intervalId;
};
// --- Patch clearInterval ---
this.#hostWindow.clearInterval = function (id) {
for (const resources of self.#trackedResources.values()) {
if (resources.intervals.has(id)) {
resources.intervals.delete(id);
break;
}
}
return self.#originalClearInterval.call(self.#hostWindow, id);
};
}
}
/**
* @class DomBatchProcessor - 高性能DOM批量处理调度器
*/
class DomBatchProcessor {
#taskQueue = [];
#registeredTasks = new Map();
#isLoopRunning = false;
#batchSize;
#logger;
#hostDocument;
#watcher;
#watcherSubscriptionId = null;
/**
* @param {number} batchSize - 在每个渲染帧中处理的最大任务数。
* @param {LoggerInstance} logger - 用于日志输出的 logger 对象。
* @param {Document} hostDocument - 宿主 document 对象。
* @param {DomWatcherService} watcher - 统一的DOM观察者服务实例。
*/
constructor(batchSize, logger, hostDocument, watcher) {
this.#batchSize = batchSize || 20;
this.#logger = logger;
this.#hostDocument = hostDocument;
this.#watcher = watcher;
}
/**
* 注册一个DOM处理任务。
* @param {string} selector - 用于匹配节点的CSS选择器。
* @param {(node: HTMLElement) => void} callback - 匹配到节点时要执行的回调函数。
* @param {object} [options] - (可选) 监听选项。
* @param {boolean} [options.add=true] - 是否监听节点的添加。
* @param {boolean} [options.attributes=false] - 是否监听节点属性的变化。
* @param {string[]} [options.attributeFilter] - (可选) 只监听特定属性的变化。
* @param {HTMLElement | string} [options.root] - (可选) 任务的根节点或根选择器。
* @param {boolean} [options.processExisting=false] - (可选) 是否在注册时立即处理DOM中已存在的匹配节点。
* @returns {Symbol} 一个唯一的任务ID,用于后续注销。
*/
register(selector, callback, options = {}) {
const taskId = Symbol(selector);
this.#registeredTasks.set(taskId, {
selector,
callback,
options: { add: true, ...options }, // 默认监听添加事件
});
this.#logger.log(
`Task registered for selector "${selector}"`,
options.root ? `within root "${options.root}"` : "",
);
if (options.processExisting) {
const rootNode =
(typeof options.root === "string"
? this.#hostDocument.querySelector(options.root)
: options.root) || this.#hostDocument;
// querySelectorAll 在找不到 rootNode 时会抛错,需要保护
if (rootNode) {
const existingNodes = rootNode.querySelectorAll(selector);
if (existingNodes.length > 0) {
this.#logger.log(
`Explicitly processing ${existingNodes.length} existing node(s) for selector "${selector}".`,
);
existingNodes.forEach((node) => {
this.#taskQueue.push({ node, callback: callback });
});
this.#startLoop();
}
}
}
this.#updateSubscription();
return taskId;
}
/**
* 注销一个DOM处理任务。
* @param {Symbol} taskId - 注册时返回的任务ID。
*/
unregister(taskId) {
if (this.#registeredTasks.has(taskId)) {
const selector = this.#registeredTasks.get(taskId).selector;
this.#registeredTasks.delete(taskId);
this.#logger.log(`Task for selector "${selector}" unregistered.`);
this.#updateSubscription();
}
}
/**
* 根据当前是否有任务,决定是订阅还是退订统一的DOM观察者。
* @private
*/
#updateSubscription() {
const hasTasks = this.#registeredTasks.size > 0;
if (hasTasks && !this.#watcherSubscriptionId) {
// 有任务但尚未订阅 -> 订阅
this.#watcherSubscriptionId = this.#watcher.subscribe(
this.#handleMutations,
);
this.#logger.log("Subscribed to DomWatcherService.");
} else if (!hasTasks && this.#watcherSubscriptionId) {
// 无任务但仍在订阅 -> 退订
this.#watcher.unsubscribe(this.#watcherSubscriptionId);
this.#watcherSubscriptionId = null;
this.#logger.log("Unsubscribed from DomWatcherService.");
}
}
/**
* 从 DomWatcherService 接收原始情报的回调,负责过滤和排队任务。
* @param {MutationRecord[]} mutations
* @private
*/
#handleMutations = (mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType === Node.ELEMENT_NODE) {
this.#queueMatchingTasks(addedNode, "add");
// 同时检查新增节点下的所有子元素是否也匹配
const descendants = addedNode.querySelectorAll("*");
for (const descendant of descendants) {
this.#queueMatchingTasks(descendant, "add");
}
}
}
} else if (mutation.type === "attributes") {
if (mutation.target.nodeType === Node.ELEMENT_NODE) {
this.#queueMatchingTasks(
mutation.target,
"attributes",
mutation.attributeName,
);
}
}
}
// 只要有新任务入队,就确保 rAF 循环在运行
if (this.#taskQueue.length > 0) {
this.#startLoop();
}
};
/**
* 辅助函数:根据每个任务各自的 options 过滤情报,并将匹配的任务排队。
* @private
*/
#queueMatchingTasks(node, mutationType, attributeName = null) {
for (const task of this.#registeredTasks.values()) {
// --- 根节点靶向检查 ---
if (task.options.root) {
const rootNode =
typeof task.options.root === "string"
? this.#hostDocument.querySelector(task.options.root)
: task.options.root;
if (!rootNode || !rootNode.contains(node)) {
continue;
}
}
// --- 核心过滤逻辑 ---
// 1. 检查任务是否关心此类变更
if (mutationType === "add" && !task.options.add) continue;
if (mutationType === "attributes" && !task.options.attributes)
continue;
// 2. 对于属性变更,额外检查 attributeFilter
if (
mutationType === "attributes" &&
Array.isArray(task.options.attributeFilter)
) {
if (!task.options.attributeFilter.includes(attributeName)) {
continue; // 属性名不匹配,跳过此任务
}
}
// 3. 检查节点是否匹配最终的 CSS 选择器
if (node.matches(task.selector)) {
this.#taskQueue.push({ node, callback: task.callback });
}
}
}
/**
* 启动 rAF 循环。
* @private
*/
#startLoop() {
if (this.#isLoopRunning) return;
this.#isLoopRunning = true;
requestAnimationFrame(this.#processQueue);
}
/**
* rAF 循环的核心,负责分批处理任务。
* @private
*/
#processQueue = () => {
const batch = this.#taskQueue.splice(0, this.#batchSize);
for (const task of batch) {
try {
if (this.#hostDocument.documentElement.contains(task.node)) {
task.callback(task.node);
}
} catch (e) {
this.#logger.error("Error in DomBatchProcessor task callback:", e);
}
}
if (this.#taskQueue.length > 0) {
requestAnimationFrame(this.#processQueue);
} else {
this.#isLoopRunning = false;
}
};
}
/**
* @class LoggerService - 专用的、可派生的日志服务
*/
class LoggerService {
#isDebug;
#baseTagStyle = `color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;`;
constructor(isDebug) {
this.#isDebug = Boolean(isDebug);
}
/**
* (私有辅助函数) 创建一组核心的日志方法。
* @private
*/
#createLogMethodsFor(tag, styles) {
return {
log: (message, ...args) =>
this.#isDebug &&
console.log(`%c${tag}`, styles.log, message, ...args),
warn: (message, ...args) =>
this.#isDebug &&
console.warn(`%c${tag}`, styles.warn, message, ...args),
error: (message, ...args) =>
console.error(`%c${tag}`, styles.error, message, ...args),
};
}
/**
* 创建一个主 logger 实例。
*/
createMainLogger(appName) {
const styles = {
log: `background-color: #0057b8; ${this.#baseTagStyle}`,
warn: `background-color: #ff9800; color: black; ${this.#baseTagStyle}`,
error: `background-color: #f44336; ${this.#baseTagStyle}`,
};
const mainLogger = this.#createLogMethodsFor(appName, styles);
mainLogger.createTaggedLogger = (tag, styleOptions = {}) => {
const taggedStyles = {
log: `background-color: ${
styleOptions.backgroundColor || "#757575"
}; color: ${styleOptions.color || "white"}; ${this.#baseTagStyle}`,
warn: `background-color: ${
styleOptions.backgroundColor || "#757575"
}; color: ${styleOptions.color || "white"}; ${this.#baseTagStyle}`,
error: `background-color: #f44336; color: white; ${
this.#baseTagStyle
}`, // 错误总是红色
};
return this.#createLogMethodsFor(tag, taggedStyles);
};
return mainLogger;
}
}
/**
* 框架内部工具与宿主补丁协调中心
*/
const _CaliberInternals = {
/**
* @private
* 确定性 FNV-1a 哈希算法,用于生成绝对唯一的标识符。
*/
_fnv1aHash: (str) => {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash +=
(hash << 1) +
(hash << 4) +
(hash << 7) +
(hash << 8) +
(hash << 24);
}
return (hash >>> 0).toString(36).padStart(7, "0").slice(0, 8);
},
/**
* @private
* 获取或初始化【沙箱侧】宿主全局单例补丁协调中心(Sandbox History Hub)。
* 注:Caliber 采用双 Hub 架构:
* 1. Sandbox History Hub(位于宿主 Window 的 Symbol 中):管理各沙箱实例的路由感知与引用计数;
* 2. Page Network Hub(位于页面真实 Window 的 __CALIBER_PAGE_NETWORK_HUB_V1__):管理真实页面 Fetch/XHR 拦截管线。
*/
_getOrCreatePatchHub: (hostWindow) => {
const HUB_KEY = Symbol.for("__CALIBER_SANDBOX_HISTORY_HUB_V1__");
if (!hostWindow[HUB_KEY]) {
const historyListeners = new Set();
const origPush = hostWindow.history.pushState;
const origReplace = hostWindow.history.replaceState;
hostWindow.history.pushState = function (...args) {
origPush.apply(this, args);
historyListeners.forEach((cb) => {
try {
cb();
} catch (e) {}
});
};
hostWindow.history.replaceState = function (...args) {
origReplace.apply(this, args);
historyListeners.forEach((cb) => {
try {
cb();
} catch (e) {}
});
};
const popstateHandler = () => {
historyListeners.forEach((cb) => {
try {
cb();
} catch (e) {}
});
};
hostWindow.addEventListener("popstate", popstateHandler);
hostWindow[HUB_KEY] = {
subscribeHistory(cb) {
historyListeners.add(cb);
return () => {
historyListeners.delete(cb);
if (historyListeners.size === 0) {
hostWindow.history.pushState = origPush;
hostWindow.history.replaceState = origReplace;
hostWindow.removeEventListener("popstate", popstateHandler);
delete hostWindow[HUB_KEY];
}
};
},
};
}
return hostWindow[HUB_KEY];
},
/**
* @private
* [上下文创建器] 从应用名称派生出所有需要的上下文状态。
* @param {string} appName - 原始的应用名称。
* @returns {{safeAppName: string, instanceKey: string}} 包含派生状态的上下文对象。
*/
_createAppContext: (appName) => {
const utf8Bytes = new TextEncoder().encode(appName);
let binaryString = "";
utf8Bytes.forEach((byte) => {
binaryString += String.fromCharCode(byte);
});
let safeAppName = btoa(binaryString);
safeAppName = safeAppName
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const instanceKey = `CALIBER_INSTANCE_${safeAppName}`;
return { safeAppName, instanceKey };
},
/**
* @private
* [预检器] 对应用配置和运行环境执行所有预检。
* @param {object} options - createApp 接收的原始选项。
* @param {string} instanceKey - 由 _createAppContext 生成的实例键。
* @param {object} logger - 用于报告错误的主 logger。
* @param {Window} hostWindow - 宿主页面的 window 对象。
* @returns {boolean} - 所有检查通过返回 true,否则返回 false。
*/
runPreflightChecks: (options, instanceKey, logger, hostWindow) => {
const { appName, modules, services } = options || {};
if (
!options ||
typeof options !== "object" ||
!appName ||
typeof appName !== "string" ||
!Array.isArray(modules) ||
!services ||
!services.storage ||
!services.command
) {
logger.error("Preflight check failed: Invalid configuration object.");
return false;
}
if (modules.length === 0) {
if (options.isDebug)
logger.log(`Preflight check skipped: No modules provided.`);
return false;
}
if (hostWindow[instanceKey]) {
logger.warn(
"Preflight check failed: Script instance already running.",
);
return false;
}
return true;
},
/**
* 初始化所有框架核心服务。
* @param {object} options - createApp 接收的原始选项。
* @param {{safeAppName: string}} context - 部分上下文,包含 safeAppName。
* @param {LoggerInstance} logger - 主 logger。
* @param {Window} hostWindow - 宿主页面的 window 对象。
* @param {Document} hostDocument - 宿主页面的 document 对象。
* @param {DOMSanitizerInstance} sanitizer - DOM净化服务实例。
* @returns {{eventBus: EventBusInstance, framework: FrameworkServices}} - 包含事件总线和框架服务集合的对象。
*/
initializeCoreServices: (
options,
context,
logger,
hostWindow,
hostDocument,
sanitizer,
) => {
const eventBus = _CaliberInternals.createEventBus(logger);
const schedulerLogger = logger.createTaggedLogger("Scheduler", {
backgroundColor: "#4CAF50",
});
const domWatcher = new DomWatcherService(logger, hostDocument);
const frameworkServices = {
scheduler: new DomBatchProcessor(
options.framework?.domProcessorBatchSize,
schedulerLogger,
hostDocument,
domWatcher,
),
interceptor: _CaliberInternals.createFetchInterceptor(
logger,
hostWindow,
hostDocument,
context.safeAppName,
options.isDebug,
sanitizer,
),
sanitizer: sanitizer,
executor: _CaliberInternals.createPageScopeExecutor(
logger,
context.safeAppName,
sanitizer,
hostDocument,
),
utils: {
checkMatch: (rule, win = hostWindow) =>
_CaliberInternals._checkMatch(rule, win),
},
_internal_domWatcher: domWatcher,
};
// 通过全局单例补丁协调中心统一监听路由,防止多次代理套娃
const hub = _CaliberInternals._getOrCreatePatchHub(hostWindow);
const unsubscribeHistory = hub.subscribeHistory(() =>
eventBus.emit("navigate"),
);
eventBus.on("kernel:destroy", unsubscribeHistory);
return { eventBus, framework: frameworkServices };
},
/**
* @private
* 核心匹配引擎 - 检查给定的匹配规则是否与当前页面URL匹配。
* @param {string|RegExp|Array<string|RegExp>|null|undefined} matchRule - 匹配规则。
* @param {Window} hostWindow - 宿主 window 对象。
* @returns {object|false} - 不匹配返回false,匹配返回 { params, query }。
*/
_checkMatch: (matchRule, hostWindow) => {
const currentUrl = new URL(hostWindow.location.href);
// 处理查询参数
const query = {};
const searchParams = currentUrl.searchParams;
for (const [key, value] of searchParams.entries()) {
const existing = query[key];
if (existing !== undefined) {
query[key] = Array.isArray(existing)
? [...existing, value]
: [existing, value];
} else {
query[key] = value;
}
}
// 路径规范化
const rawPathname = currentUrl.pathname;
const pathname =
rawPathname.endsWith("/") && rawPathname.length > 1
? rawPathname.slice(0, -1)
: rawPathname;
const href = currentUrl.href;
// 空规则快速返回
if (matchRule == null) {
return { params: {}, query };
}
// 规则数组处理
const rules = Array.isArray(matchRule) ? matchRule : [matchRule];
// 核心匹配逻辑
for (const rule of rules) {
const result = checkRule(rule);
if (result) return result;
}
return false;
// 辅助函数保持内部作用域
function checkRule(rule) {
if (rule == null) return { params: {}, query };
if (!rule) return false;
// 正则表达式规则
if (rule instanceof RegExp) {
const match = rule.exec(pathname) || rule.exec(href);
return match ? { params: match.groups || {}, query } : false;
}
// 字符串规则
if (typeof rule === "string") {
let isAbsolute = false;
let rulePath = rule;
let ruleProtocol = "";
let ruleHost = "";
try {
const urlObj = new URL(rule);
isAbsolute = true;
ruleProtocol = urlObj.protocol;
ruleHost = urlObj.host;
rulePath = urlObj.pathname;
} catch {}
if (isAbsolute) {
if (
ruleProtocol !== currentUrl.protocol ||
ruleHost !== currentUrl.host
) {
return false;
}
}
// 路径规范化
const normalizedRule =
rulePath.endsWith("/") && rulePath.length > 1
? rulePath.slice(0, -1)
: rulePath;
// 快速前缀匹配(无参数路径)
if (
pathname === normalizedRule ||
pathname.startsWith(normalizedRule + "/")
) {
return { params: {}, query };
}
// 参数化路径匹配
return matchParamPath(normalizedRule);
}
return false;
}
// 参数化路径匹配
function matchParamPath(pattern) {
// 检查是否需要参数匹配
const hasParams = pattern.includes(":") || pattern.includes("*");
if (!hasParams) return false;
// 构建正则表达式
const parts = pattern.split("/").slice(1);
let regexStr = "^";
const paramNames = [];
let hasWildcard = false;
for (const part of parts) {
if (hasWildcard) return false; // 通配符后不能有其他部分
if (part.startsWith(":")) {
const isOptional = part.endsWith("?");
const name = isOptional ? part.slice(1, -1) : part.slice(1);
paramNames.push(name);
regexStr += isOptional ? "(?:/([^/]+))?" : "/([^/]+)";
} else if (part === "*") {
paramNames.push("_");
regexStr += "(?:/(.*))?";
hasWildcard = true;
} else {
regexStr += "/" + part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
}
regexStr += "$";
const regex = new RegExp(regexStr);
const match = regex.exec(pathname);
if (match) {
const params = {};
for (let i = 0; i < paramNames.length; i++) {
params[paramNames[i]] = match[i + 1] ?? undefined;
}
return { params, query };
}
return false;
}
},
/**
* 创建一个事件总线实例。 (工厂职责)
* @param {object} logger - 用于错误报告的logger实例。
*/
createEventBus: (logger) => {
const listeners = new Map();
const log = logger || console;
return {
on: (eventName, callback) => {
if (!listeners.has(eventName)) {
listeners.set(eventName, []);
}
listeners.get(eventName).push(callback);
},
off: (eventName, callback) => {
if (listeners.has(eventName)) {
const eventListeners = listeners.get(eventName);
const index = eventListeners.indexOf(callback);
if (index > -1) {
eventListeners.splice(index, 1);
}
}
},
emit: (eventName, data) => {
if (listeners.has(eventName)) {
[...listeners.get(eventName)].forEach((callback) => {
try {
callback(data);
} catch (e) {
log.error(
`[EventBus] Error in callback for event "${eventName}":`,
e,
);
}
});
}
},
};
},
/**
* @private
* [服务工厂] 创建一个通用的DOM净化与安全注入服务。
* 这是框架中所有CSP/Trusted-Types对抗策略的唯一来源。
* @param {object} logger - 用于报告错误的 logger 实例。
* @param {string} [safeAppName="Default"] - (可选) 应用的安全名称,用于生成唯一的策略名。
* @returns {object} DOMSanitizer 服务实例。
*/
createDOMSanitizer: (logger, safeAppName = "Default") => {
let _policy = undefined; // 使用闭包缓存 Trusted Types 策略
const _getPolicy = () => {
if (_policy === undefined) {
_policy = null;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
const policyName = `CaliberPolicy_${safeAppName}#html`;
_policy = window.trustedTypes.createPolicy(policyName, {
createHTML: (s) => s,
createScript: (s) => s,
});
} catch (e) {
if (window.trustedTypes.defaultPolicy)
_policy = window.trustedTypes.defaultPolicy;
}
}
}
return _policy;
};
const service = {
/**
* [核心] 创建一个TrustedHTML对象(如果策略可用)。
* @param {string} htmlString - 要处理的HTML字符串。
* @returns {TrustedHTML|string} 返回TrustedHTML对象或原始字符串。
*/
createTrustedHTML(htmlString) {
const policy = _getPolicy();
return policy ? policy.createHTML(htmlString) : htmlString;
},
/**
* [便捷方法] 安全地设置一个元素的 innerHTML。
* @param {Element} element - 目标元素。
* @param {string} htmlString - 要设置的HTML字符串。
*/
setInnerHTML(element, htmlString) {
try {
element.innerHTML = this.createTrustedHTML(htmlString);
} catch (e) {
logger.error(
"setInnerHTML failed due to CSP.",
`Error: ${e.message}`,
);
}
},
/**
* [便捷方法] 安全地注入脚本。
* @param {Document} doc - 目标文档。
* @param {string} codeString - 脚本字符串。
*/
injectScript(doc, codeString) {
try {
const script = doc.createElement("script");
const policy = _getPolicy();
if (policy) script.textContent = policy.createScript(codeString);
else script.textContent = codeString;
(doc.head || doc.documentElement).prepend(script);
script.remove();
} catch (e) {
logger.error(
"Script injection failed due to CSP.",
`Error: ${e.message}`,
);
}
},
/**
* [便捷方法] 安全地注入样式。
* @param {Document} doc - 目标文档。
* @param {string} cssString - 样式字符串。
* @param {string} id - 样式元素的ID。
* @returns {HTMLStyleElement|null} 创建的元素或null。
*/
injectStyle(doc, cssString, id) {
try {
const style = doc.createElement("style");
style.dataset.caliberId = id;
style.innerHTML = this.createTrustedHTML(cssString);
doc.head.appendChild(style);
return style;
} catch (e) {
logger.error(
"Style injection failed due to CSP.",
`Error: ${e.message}`,
);
return null;
}
},
};
return service;
},
/**
* 网络请求拦截器服务。
* 依托宿主全局单例 Patch Hub(__CALIBER_NATIVE_PATCH_HUB_V1__)实现单次代理、管道式 FIFO 链式调度与异常隔离。
*
* @param {object} logger - 框架的主 logger 实例。
* @param {Window} hostWindow - 宿主页面的 window 对象。
* @param {Document} hostDocument - 宿主页面的 Document 对象。
* @param {string} safeAppName - 当前应用名称,用于生成唯一的命名空间。
* @param {boolean} isDebug - 是否为调试模式,用于控制注入脚本的日志输出。
* @param {DOMSanitizerInstance} sanitizer - DOM净化与安全注入服务实例。
* @returns {FetchInterceptorInstance} 服务对象。
*/
createFetchInterceptor: (
logger,
hostWindow,
hostDocument,
safeAppName,
isDebug,
sanitizer,
) => {
const namespace = `__CALIBER_${safeAppName}`;
const responseEventName = `${namespace}_RESPONSE`;
const responseCallbacks = new Map();
let listenerRefCount = 0;
// 事件处理函数
const _handleInjectedEvent = (event) => {
const { path, responseData } = event.detail;
const pathKey = JSON.stringify(path);
if (responseCallbacks.has(pathKey)) {
try {
responseCallbacks.get(pathKey)(responseData);
} catch (e) {
logger.error(
`[FetchInterceptor] Error in response callback for path [${path.join(
"/",
)}]`,
e,
);
}
}
};
// 统一处理路径验证和转换
const _validateAndTransformPath = (path, methodName) => {
if (typeof path === "string" && path) return [path];
if (Array.isArray(path) && path.length > 0) return path;
logger.error(
`[FetchInterceptor] ${methodName} failed: path must be a non-empty array or a non-empty string.`,
);
return null;
};
// 确保在页面环境初始化宿主原生 Patch Hub 单例(仅执行一次注入与原生包装)
const _ensureHubBootstrapped = () => {
const bootstrapCode = `
(() => {
const HUB_KEY = '__CALIBER_PAGE_NETWORK_HUB_V1__';
if (window[HUB_KEY]) return;
const hub = {
hooks: new Map(), // key -> { path, responseEventName, doLog, fn }
originalFetch: window.fetch,
originalXhrOpen: window.XMLHttpRequest ? window.XMLHttpRequest.prototype.open : null,
originalXhrSend: window.XMLHttpRequest ? window.XMLHttpRequest.prototype.send : null,
originalXhrSetHeader: window.XMLHttpRequest ? window.XMLHttpRequest.prototype.setRequestHeader : null,
// 管道式 FIFO 执行链与严格异常隔离
executePipeline(url, config) {
let currentUrl = url;
let currentConfig = config;
const matchedEntries = [];
let isModified = false;
for (const [key, entry] of hub.hooks.entries()) {
try {
const result = entry.fn(currentUrl, currentConfig);
if (result && result.url && result.config) {
if (result.url !== currentUrl || result.config !== currentConfig) {
isModified = true;
}
currentUrl = result.url;
currentConfig = result.config;
if (entry.responseEventName) {
matchedEntries.push(entry);
}
}
} catch (err) {
if (entry.doLog) {
console.error('[Caliber Hub Pipeline Error in hook: ' + key + ']', err);
}
}
}
return { url: currentUrl, config: currentConfig, matchedEntries, isModified };
}
};
window[HUB_KEY] = hub;
// 1. 全局 Fetch 原生单例 Patch
window.fetch = async function(input, init) {
let urlStr = '';
let config = init || {};
if (input instanceof Request) {
urlStr = input.url;
config = {
method: input.method,
headers: input.headers,
...config
};
} else {
urlStr = String(input);
}
let url;
try {
url = new URL(urlStr, window.location.origin).toString();
} catch (e) {
url = urlStr;
}
const { url: finalUrl, config: finalConfig, matchedEntries, isModified } = hub.executePipeline(url, config);
let finalInput = input;
let finalInit = init;
if (finalUrl !== url || finalConfig !== config || isModified) {
if (input instanceof Request) {
finalInput = new Request(finalUrl, { ...config, ...finalConfig });
finalInit = undefined;
} else {
finalInput = finalUrl;
finalInit = finalConfig;
}
}
const response = await hub.originalFetch.call(this, finalInput, finalInit);
if (matchedEntries.length > 0) {
const responseClone = response.clone();
responseClone.text().then(text => {
let responseData;
try {
responseData = JSON.parse(text);
} catch (e) {
responseData = text;
}
matchedEntries.forEach(entry => {
document.dispatchEvent(new CustomEvent(entry.responseEventName, {
detail: { path: entry.path, responseData }
}));
});
}).catch(() => {});
}
return response;
};
// 2. 全局 XHR 原生单例 Patch(原生语义校验 + 分阶段延迟应用 Hook 管线)
if (window.XMLHttpRequest) {
window.XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._method = method || 'GET';
this._url = url || '';
this._headers = {};
this._openArgs = args;
this._matchedEntries = [];
return hub.originalXhrOpen.apply(this, [method, url, ...args]);
};
window.XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
// 1. 先让原生执行(保证未 open 抛错、原生状态机校验与重复 Header 原生追加语义)
hub.originalXhrSetHeader.call(this, header, value);
// 2. 同步记录到框架状态机(供后续 pipeline 提取完整 Headers)
if (!this._headers) this._headers = {};
if (this._headers[header]) {
this._headers[header] += ', ' + value;
} else {
this._headers[header] = value;
}
};
window.XMLHttpRequest.prototype.send = function(body) {
let absoluteUrl;
try {
absoluteUrl = new URL(this._url, window.location.origin).toString();
} catch (e) {
absoluteUrl = this._url;
}
const reqConfig = {
method: this._method,
headers: { ...(this._headers || {}) },
body: body
};
const { url: finalUrl, config: finalConfig, matchedEntries } =
hub.executePipeline(absoluteUrl, reqConfig);
this._matchedEntries = matchedEntries;
// 检查 Hook 是否对 Header 产生了新增或篡改
let headersModified = false;
const oldHeaders = this._headers || {};
const newHeaders = (finalConfig.headers && typeof finalConfig.headers === 'object') ? finalConfig.headers : {};
const oldKeys = Object.keys(oldHeaders);
const newKeys = Object.keys(newHeaders);
if (oldKeys.length !== newKeys.length) {
headersModified = true;
} else {
for (const h of newKeys) {
if (newHeaders[h] !== oldHeaders[h]) {
headersModified = true;
break;
}
}
}
// 仅当 URL、Method 发生改变,或 Header 被 Hook 篡改时,重新 open 并保护实例属性
if (finalUrl !== absoluteUrl || finalConfig.method !== this._method || headersModified) {
const prevTimeout = this.timeout;
const prevWithCredentials = this.withCredentials;
const prevResponseType = this.responseType;
hub.originalXhrOpen.apply(this, [finalConfig.method || this._method, finalUrl, ...(this._openArgs || [])]);
this.timeout = prevTimeout;
this.withCredentials = prevWithCredentials;
try { this.responseType = prevResponseType; } catch (e) {}
if (finalConfig.headers && typeof finalConfig.headers === 'object') {
for (const h in finalConfig.headers) {
if (Object.prototype.hasOwnProperty.call(finalConfig.headers, h)) {
hub.originalXhrSetHeader.call(this, h, finalConfig.headers[h]);
}
}
}
}
if (this._matchedEntries && this._matchedEntries.length > 0) {
this.addEventListener('load', () => {
let responseData;
try {
responseData = JSON.parse(this.responseText);
} catch (e) {
responseData = this.responseText;
}
this._matchedEntries.forEach(entry => {
document.dispatchEvent(new CustomEvent(entry.responseEventName, {
detail: { path: entry.path, responseData }
}));
});
});
}
return hub.originalXhrSend.call(this, finalConfig.body !== undefined ? finalConfig.body : body);
};
}
})();
`;
sanitizer.injectScript(hostDocument, bootstrapCode);
};
const service = {
/**
* 根据配置对象构建一个 fetch 钩子函数的字符串。
* 这是一个便捷的“填空题”工具,用于简化 addHook 的使用。
* @param {object} options - 钩子配置。
* @param {string} options.targetUrl - 必须完全匹配的目标URL (origin + pathname)。
* @param {string} [options.method='GET'] - (可选) 匹配的HTTP方法 (大小写不敏感)。
* @param {string} options.handler - 在匹配成功后,要执行的核心逻辑的函数体字符串。
* 在此字符串中,你可以使用 `urlObject` 和 `config` 这两个变量。
* 它必须返回一个 `{ url: string, config: object }` 或 `undefined`。
* @returns {string} - 一个完整的、自包含的、可注入的钩子函数字符串。
*/
createHook({ targetUrl, method = "GET", handler }) {
if (!targetUrl || !handler) {
logger.error(
`[FetchInterceptor.createHook] failed: 'targetUrl' and 'handler' are required.`,
);
return `() => {}`;
}
const template = `
(url, config) => {
const TARGET_URL = '${targetUrl}';
const TARGET_METHOD = '${method.toUpperCase()}';
try {
const reqMethod = (config.method || 'GET').toUpperCase();
if (reqMethod !== TARGET_METHOD) return;
const urlObject = new URL(url);
if (urlObject.origin + urlObject.pathname !== TARGET_URL) return;
const result = (() => { ${handler} })();
return result;
} catch (e) { /* ignore errors */ }
}`;
return template;
},
/**
* 添加一个网络请求钩子,并注册一个用于处理响应的回调。
* @param {string[]|string} path - 钩子的唯一路径。
* @param {string} hookFunctionString - 修改请求的钩子函数字符串。
* @param {(responseData: any) => void} responseCallback - 接收响应数据的回调函数。
*/
addHookWithResponse(path, hookFunctionString, responseCallback) {
const finalPath = _validateAndTransformPath(
path,
"addHookWithResponse",
);
if (!finalPath) return;
if (typeof responseCallback !== "function") {
logger.error(
"[FetchInterceptor] addHookWithResponse failed: responseCallback must be a function.",
);
return;
}
const pathKey = JSON.stringify(finalPath);
if (!responseCallbacks.has(pathKey)) {
if (listenerRefCount === 0) {
hostDocument.addEventListener(
responseEventName,
_handleInjectedEvent,
);
if (isDebug)
logger.log(
`[FetchInterceptor] Global response listener attached for event: ${responseEventName}`,
);
}
listenerRefCount++;
}
responseCallbacks.set(pathKey, responseCallback);
this.addHook(finalPath, hookFunctionString, true);
},
/**
* 添加或更新一个网络请求钩子(注册至全局 Patch Hub)。
* @param {string[]|string} path - 钩子路径。
* @param {string} hookFunctionString - 钩子函数字符串。
* @param {boolean} [awaitsResponse=false] - (内部) 标记此钩子是否需要返回响应。
*/
addHook(path, hookFunctionString, awaitsResponse = false) {
const finalPath = _validateAndTransformPath(path, "addHook");
if (!finalPath || !hookFunctionString) {
if (!hookFunctionString)
logger.error(
"[FetchInterceptor] addHook failed: hookFunctionString is required.",
);
return;
}
_ensureHubBootstrapped();
const pathJson = JSON.stringify(finalPath);
const appScopedKey = `${safeAppName}::${pathJson}`;
const registrationCode = `
(() => {
const hub = window['__CALIBER_PAGE_NETWORK_HUB_V1__'];
if (!hub) return;
hub.hooks.set(${JSON.stringify(appScopedKey)}, {
path: ${pathJson},
responseEventName: ${awaitsResponse ? `'${responseEventName}'` : "null"},
doLog: ${isDebug},
fn: (${hookFunctionString})
});
if (${isDebug}) console.log(\`[Caliber Hub] Hook at path [${finalPath.join(
"/",
)}] registered.\`);
})();
`;
sanitizer.injectScript(hostDocument, registrationCode);
},
/**
* 从宿主全局 Patch Hub 注册表中移除一个钩子。
* @param {string[]|string} path - 要移除的钩子的路径。
*/
removeHook(path) {
const finalPath = _validateAndTransformPath(path, "removeHook");
if (!finalPath) return;
const pathKey = JSON.stringify(finalPath);
if (responseCallbacks.has(pathKey)) {
responseCallbacks.delete(pathKey);
listenerRefCount--;
if (listenerRefCount === 0) {
hostDocument.removeEventListener(
responseEventName,
_handleInjectedEvent,
);
if (isDebug)
logger.log(
`[FetchInterceptor] Global response listener removed as no hooks are active.`,
);
}
if (isDebug)
logger.log(
`[FetchInterceptor] Response callback for path [${finalPath.join(
"/",
)}] removed.`,
);
}
const pathJson = JSON.stringify(finalPath);
const appScopedKey = `${safeAppName}::${pathJson}`;
const removalCode = `
(() => {
const hub = window['__CALIBER_PAGE_NETWORK_HUB_V1__'];
if (!hub) return;
hub.hooks.delete(${JSON.stringify(appScopedKey)});
if (${isDebug}) console.log(\`[Caliber Hub] Hook at path [${finalPath.join(
"/",
)}] removed.\`);
})();
`;
sanitizer.injectScript(hostDocument, removalCode);
},
/**
* 启动一个链式调用来创建和注册一个拦截器。
* @param {string|{url: string, method?: string, match?: string|RegExp|Array<string|RegExp>}} urlOrOptions - 目标URL或一个包含URL、方法和页面匹配规则的对象。
* @returns {object} 一个包含 .onRequest(), .onResponse(), .register() 的构建器对象。
*/
target(urlOrOptions) {
const builder = {
_targetConfig: {},
_requestHandlerStr: `(url, config) => ({ url, config })`,
_responseCallback: null,
_init(targetConfig) {
this._targetConfig = targetConfig;
return this;
},
/**
* 定义请求被拦截时要执行的逻辑。
* @param {string} handlerString - 一个将要被注入的函数体字符串。
* @returns {builder}
*/
onRequest(handlerString) {
this._requestHandlerStr = handlerString;
return this;
},
/**
* 定义在沙箱中处理响应数据的回调函数。
* @param {(responseData: any) => void} callback - 回调函数。
* @returns {builder}
*/
onResponse(callback) {
this._responseCallback = callback;
return this;
},
/**
* 最终确定并注册这个拦截器。
* @param {string|string[]} id - 拦截器的唯一ID,通常是模块的this.id。
*/
register(id) {
const { match } = this._targetConfig;
const isMatched = _CaliberInternals._checkMatch(
match,
hostWindow,
);
if (!isMatched) {
if (isDebug) {
logger.log(
`[Interceptor.register] Registration for ID '${id}' skipped. Current URL "${hostWindow.location.href}" does not match the rule:`,
match,
);
}
return;
}
if (!id) {
logger.error(
"[Interceptor.register] An ID is required to register a hook.",
);
return;
}
const isRequestModified =
this._requestHandlerStr !==
`(url, config) => ({ url, config })`;
const isResponseHandled = this._responseCallback !== null;
if (!isRequestModified && !isResponseHandled) {
if (isDebug) {
logger.warn(
`[Interceptor.register] Registration for ID '${id}' was silently cancelled because both onRequest and onResponse were omitted.`,
);
}
return;
}
const finalHandler = isRequestModified
? this._requestHandlerStr
: `return { url, config };`;
const fullHookString = service.createHook({
targetUrl: this._targetConfig.url,
method: this._targetConfig.method,
handler: finalHandler,
});
if (isResponseHandled) {
service.addHookWithResponse(
id,
fullHookString,
this._responseCallback,
);
} else {
service.addHook(id, fullHookString);
}
},
};
const targetConfig =
typeof urlOrOptions === "string"
? { url: urlOrOptions, method: "GET" }
: { method: "GET", ...urlOrOptions };
return builder._init(targetConfig);
},
/**
* 销毁当前应用的网络拦截器服务,清理沙箱事件监听并批量注销页面 Hub 中属于当前 App 的所有 Hooks。
*/
destroy() {
if (listenerRefCount > 0) {
hostDocument.removeEventListener(
responseEventName,
_handleInjectedEvent,
);
listenerRefCount = 0;
}
responseCallbacks.clear();
const appPrefixJson = JSON.stringify(`${safeAppName}::`);
const purgeCode = `
(() => {
const hub = window['__CALIBER_PAGE_NETWORK_HUB_V1__'];
if (!hub || !hub.hooks) return;
const prefix = ${appPrefixJson};
for (const key of hub.hooks.keys()) {
if (typeof key === 'string' && key.startsWith(prefix)) {
hub.hooks.delete(key);
}
}
if (${isDebug}) console.log(\`[Caliber Hub] Purged all hooks for App: ${safeAppName}\`);
})();
`;
sanitizer.injectScript(hostDocument, purgeCode);
},
};
return service;
},
/**
* @private
* [原生适配器] 基于Web标准API的默认服务实现。
*/
_nativeBrowserAdapters: {
storage: (storageKey) => ({
get: () =>
Promise.resolve(
JSON.parse(localStorage.getItem(storageKey) || "{}"),
),
set: (value) =>
Promise.resolve(
localStorage.setItem(storageKey, JSON.stringify(value)),
),
}),
command: {
register: (name, callback) => {},
},
style: (sanitizer, hostDocument, safeAppName = "app") => ({
_addedStyles: new Map(),
add(cssString, id) {
const scopedId = `${safeAppName}__${id}`; // 拼接专属命名空间
const styleElement = sanitizer.injectStyle(
hostDocument,
cssString,
scopedId,
);
if (styleElement) {
this._addedStyles.set(id, styleElement);
}
},
remove(id) {
if (this._addedStyles.has(id)) {
this._addedStyles.get(id).remove();
this._addedStyles.delete(id);
}
},
}),
},
/**
* @private
* [服务工厂] 创建一个页面作用域代码执行器服务。
* 可将任意JS代码字符串注入到宿主页面执行,并异步返回其可序列化的结果。
* @param {object} logger - 框架的主 logger 实例。
* @param {string} safeAppName - 应用的安全名称,用于生成唯一事件名。
* @param {DOMSanitizerInstance} sanitizer - DOM净化与安全注入服务实例。
* @param {Document} hostDocument - 宿主 document 对象。
* @returns {{execute: (codeString: string) => Promise<any>}} PageScopeExecutor 服务实例。
*/
createPageScopeExecutor: (
logger,
safeAppName,
sanitizer,
hostDocument,
) => {
const namespace = `__CALIBER_PAGE_EXECUTOR_${safeAppName}`;
return {
async execute(codeString) {
return new Promise((resolve, reject) => {
// 优先使用标准 crypto.randomUUID(),提供健壮且高熵的通信 ID
let requestId;
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
requestId = crypto.randomUUID();
} else {
requestId = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 11)}`;
}
const responseEventName = `${namespace}_RESPONSE_${requestId}`;
// 10 秒超时防护机制,防止 CSP 阻断或语法错误导致 Promise 挂起
const timer = setTimeout(() => {
hostDocument.removeEventListener(
responseEventName,
handleResponse,
);
reject(
new Error(
`PageScopeExecutor timed out for request: ${requestId}`,
),
);
}, 10000);
const handleResponse = (event) => {
clearTimeout(timer);
const { success, data, errorMsg } = event.detail;
hostDocument.removeEventListener(
responseEventName,
handleResponse,
);
if (success) {
resolve(data);
} else {
reject(
new Error(errorMsg || "Page-scope code execution failed."),
);
}
};
hostDocument.addEventListener(responseEventName, handleResponse, {
once: true,
});
const injectionCode = `
(async () => {
const RESPONSE_EVENT_NAME = '${responseEventName}';
try {
const result = await (${codeString});
document.dispatchEvent(new CustomEvent(RESPONSE_EVENT_NAME, {
detail: { success: true, data: result }
}));
} catch (e) {
document.dispatchEvent(new CustomEvent(RESPONSE_EVENT_NAME, {
detail: { success: false, errorMsg: e.message }
}));
}
})();
`;
sanitizer.injectScript(hostDocument, injectionCode);
});
},
};
},
};
/**
* 创建并启动一个基于 Caliber 框架的增强脚本应用。
* 这是 Caliber 框架的唯一入口点。
*
* @param {object} options - 应用的配置对象。
* @param {string} options.appName - 应用的名称。将用于日志前缀、UI标题和菜单项。
* @param {class[]} options.modules - 一个由模块类(必须继承自 Caliber.Module)组成的数组。
* @param {object} options.services - 一个包含所有平台相关服务实现的对象。
* @param {object} options.services.storage - 存储服务适配器。必须实现 get() 和 set(value) 方法。
* @param {() => Promise<object>} options.services.storage.get - 一个异步函数,返回存储的用户配置对象。
* @param {(value: object) => Promise<void>} options.services.storage.set - 一个异步函数,将配置对象写入存储。
* @param {object} options.services.command - 命令服务适配器。必须实现 register(name, callback) 方法。
* @param {(name: string, callback: () => void) => void} options.services.command.register - 一个函数,用于注册一个菜单命令。
* @param {object} [options.services.hostWindow=window] - (可选) 要操作的窗口对象。默认为油猴环境的`unsafeWindow`或标准`window`。
* @param {object} [options.services.hostDocument=document] - (可选) 要操作的文档对象。默认为`document`。
* @param {boolean} [options.isDebug=true] - (可选) 是否开启调试模式,会影响日志的输出。默认为`false`。
* @param {boolean} [options.settingsPanelEnabled=true] - (可选) 设置面板在首次启动时是否默认开启。默认为`true`。
* @param {object} [options.settingsPanel] - (可选) 设置面板及触发按钮的自定义配置。
* @param {number} [options.settingsPanel.bottom=50] - (可选) 触发按钮距离页面底部的像素值。
* @param {number} [options.settingsPanel.right=0] - (可选) 触发按钮距离页面右侧的像素值。
* @param {object} [options.framework] - (可选) 用于微调 Caliber 框架内部行为的配置。
* @param {number} [options.framework.domProcessorBatchSize=20] - (可选) 设置 DomBatchProcessor 在每个渲染帧中处理的最大任务数。
* @returns {Promise<void>}
*/
async function createApp(options) {
const {
appName,
modules,
services,
isDebug = false,
settingsPanelEnabled = true,
settingsPanel = {},
} = options || {};
// 初始化基础环境
const loggerFactory = new LoggerService(isDebug);
const mainLogger = loggerFactory.createMainLogger(
appName || "CaliberApp",
);
const hostWindow =
services.hostWindow ||
(typeof unsafeWindow !== "undefined"
? unsafeWindow
: typeof window !== "undefined"
? window
: globalThis);
const hostDocument =
services.hostDocument || hostWindow.document || document;
// 创建上下文
const context = _CaliberInternals._createAppContext(appName);
// 执行预检
if (
!_CaliberInternals.runPreflightChecks(
options,
context.instanceKey,
mainLogger,
hostWindow,
)
) {
return;
}
const sanitizer = _CaliberInternals.createDOMSanitizer(
mainLogger,
context.safeAppName,
);
// 优先使用用户提供的,否则使用框架内置的原生适配器
const finalServices = {
storage:
services.storage ||
_CaliberInternals._nativeBrowserAdapters.storage(
`CALIBER_STORAGE_${appName}`,
),
command: _CaliberInternals._nativeBrowserAdapters.command,
style:
services.style ||
_CaliberInternals._nativeBrowserAdapters.style(
sanitizer,
hostDocument,
context.safeAppName,
),
...services,
};
// 初始化核心服务
const coreServices = _CaliberInternals.initializeCoreServices(
options,
context,
mainLogger,
hostWindow,
hostDocument,
sanitizer,
);
// 组装依赖并运行内核
const injectedServices = {
hostWindow,
hostDocument,
eventBus: coreServices.eventBus,
storage: finalServices.storage,
logger: mainLogger,
style: finalServices.style,
framework: {
...coreServices.framework,
...options.framework,
},
IS_DEBUG: isDebug,
APP_NAME: appName,
SAFE_APP_NAME: context.safeAppName,
initialConfig: {
settingsPanel: {
enabled: settingsPanelEnabled,
bottom:
typeof settingsPanel.bottom === "number"
? settingsPanel.bottom
: 50,
right:
typeof settingsPanel.right === "number" ? settingsPanel.right : 0,
...settingsPanel,
},
},
};
const kernel = new AppKernel(injectedServices);
hostWindow[context.instanceKey] = kernel;
modules.forEach((ModuleClass) => kernel.registerModule(ModuleClass));
await kernel.run();
// 注册外部接口
finalServices.command.register(`⚙️ ${appName} 设置`, () => {
coreServices.eventBus.emit("command:toggle-settings-panel");
});
mainLogger.log("Bootstrap sequence complete. Application is alive.");
}
return {
createApp,
Module, // 暴露 Module 基类,以便应用脚本可以继承它
};
})();
if (typeof module !== "undefined" && module.exports) {
module.exports = Caliber;
} else {
window.Caliber = Caliber;
}
})(typeof window !== "undefined" ? window : globalThis);