您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为小黑盒PC网站提供可动态配置的增强功能,如夜间模式切换、Steam商店直达等。
// ==UserScript== // @name 小黑盒Pro // @namespace You Boy // @version 1.3.1 // @description 为小黑盒PC网站提供可动态配置的增强功能,如夜间模式切换、Steam商店直达等。 // @author You Boy // @match *://*.xiaoheihe.cn/* // @icon https://imgheybox.max-c.com/avatar/2024/12/31/a6472e5653708a6649e49e40aa7bb13f.jpeg?imageMogr2/thumbnail/100x100 // @grant GM.getValue // @grant GM.setValue // @grant GM.addStyle // @grant GM.registerMenuCommand // @grant unsafeWindow // @run-at document-start // @license MIT // ==/UserScript== // #region ================================ 核心框架 (Core Framework) ================================ const Caliber = (() => { 'use strict'; // #region --- 类型定义 (Type Definitions) --- /** * @typedef {object} DomBatchProcessorInstance 高性能DOM批量处理调度器实例 * @property {(selector: string, callback: (node: HTMLElement) => void, options?: object) => 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生成。 */ /** * @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) => Promise<void>} add - 注入一段CSS样式,并关联一个唯一ID。 * @property {(id: string) => void} remove - 根据ID移除之前注入的样式。 */ /** * @typedef {string} FetchInterceptorRequestString * * 定义当一个网络请求被成功匹配时,在**发起实际请求之前**要执行的修改逻辑。 * 这段代码在宿主页面的上下文中运行,拥有访问`window`对象的全部能力,但必须是一个无闭包依赖的纯字符串。 * * 示例:为一个API请求添加或修改一个查询参数 * .onRequest(` * [可用上下文] * - url: {string} 原始的、完整的请求URL字符串。 * - config: {object} 原始的fetch配置对象 (如 method, headers, body 等)。 * - urlObject: {URL} 由框架预先创建的URL实例,强烈推荐使用它来操作URL。 * * 1. (推荐) 使用内置的 urlObject 来修改URL * urlObject.searchParams.set('new_param', 'caliber_rocks'); * * 2. (可选) 修改 fetch 配置对象 * config.headers = { ...config.headers, 'X-Caliber-Injected': 'true' }; * * [返回契约] * 必须返回一个包含 'url' 和 'config' 键的对象。 * - url: {string} 最终将要被请求的URL字符串。 * - config: {object} 最终将要被使用的fetch配置对象。 * return { url: urlObject.toString(), config }; * `) * * 示例:添加或修改URL查询参数 * > .onRequest(` * [可用上下文] * - url: {string} 原始的、完整的请求URL字符串。 * - config: {object} 原始的fetch配置对象 (如 method, headers, body 等)。 * - urlObject: {URL} 由框架预先创建的URL实例,强烈推荐使用它来操作URL。 * * (推荐) 使用内置的 urlObject 来修改URL的查询参数 * > urlObject.searchParams.set('page', '1'); * > urlObject.searchParams.set('limit', '50'); * * 必须返回一个包含 'url' 和 'config' 键的对象。 * > return { url: urlObject.toString(), config }; * * > `) * */ /** * @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>, hostWindow?: Window) => object|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移除一个已注册的拦截器。 */ /** * @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 {FrameworkServices} framework - 框架核心服务集合。 */ /** * @typedef {object} ModuleInterface 模块类的公共API与内部属性接口 * @property {string} id - 模块的唯一标识符,用于配置和管理。 * @property {string} name - 模块的显示名称,用于UI。 * @property {string} description - 模块的功能描述,用于UI。 * @property {object} defaultConfig - 模块的默认配置。 * @property {string|RegExp|Array<string|RegExp>|null} [match=null] - (可选) 限制模块仅在匹配该规则的页面上运行。 * @property {object} [uiGuard] - (可选) 声明式UI守护配置。提供此对象将激活框架的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 {PageScopeExecutorInstance} _executor - (快捷方式) 页面作用域代码执行器 * @property {FrameworkUtils} _utils - (快捷方式) 框架提供的公共工具函数集合。 * * @property {(context: {params: object, query: object}) => void} onEnable - 当模块被启用时调用。会收到包含解析后URL参数的上下文对象。 * @property {() => void} onDisable - 当模块被禁用时调用。必须在此处清理所有副作用。 * @property {(key: string, newValue: any, oldValue: any) => void} onConfigChange - 当模块的特定配置项发生变化时调用。 * * @property {(targetElement: HTMLElement) => void} [onRender] - (守护模式生命周期) 当UI需要被渲染或恢复时调用。此方法仅在模块定义了 uiGuard 时由框架调用。 * @property {() => void} [onCleanup] - (守护模式生命周期) 当UI需要被清理时调用。此方法仅在模块定义了 uiGuard 且被禁用时由框架调用。 */ // #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; /** * @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; // 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() { } } /** * @class AppKernel - 应用程序内核 * * 负责管理模块生命周期、配置、UI和所有核心服务。 */ class AppKernel { #services = {}; #moduleManager; #configManager; #uiManager; #logger; #auditor = null; #uiGuardianService; /** * @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; // 将“运行时服务”组装到内核的 #services 对象中 this.#services = { hostWindow, hostDocument, eventBus, logger, storage, style, framework, 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.#uiManager.showPanelTrigger(); } else { this.#uiManager.hidePanelTrigger(); } } } /** * 启动应用。这是整个应用逻辑的入口点。 */ 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); } } /** * @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 (source.hasOwnProperty(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 (!template.hasOwnProperty(key)) { prunedSource[key] = source[key]; // if (this.#logger) this.#logger.log(`Dynamically added key '${key}' from user config has been preserved.`); } } return prunedSource; } } /** * @class ModuleManager - 模块管理器 * * 负责注册、实例化和管理所有模块的生命周期。 */ class ModuleManager { #services; #registeredModuleClasses = new Map(); #activeModuleInstances = new Map(); #auditor = null; #uiGuardian; 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); } /** * 注册一个模块类。 * @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(); } /** * 遍历所有已注册的模块类,收集它们的默认配置。 * @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 = (detail) => { const { path, value, newConfig } = detail; const enabledMatch = path.match(/^modules\.([^.]+)\.enabled$/); if (enabledMatch) { const moduleId = enabledMatch[1]; 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; moduleInstance.onConfigChange(key, value, oldConfig[key]); } } } /** * 封装的启用模块的逻辑 */ #enableModule(id, ModuleClass, config, matchContext) { if (this.#activeModuleInstances.has(id)) return; try { // 创建模块专用的服务门面 const moduleConfig = config.modules[id]; const configManagerFacade = { // 只暴露与当前模块相关的配置API getConfig: () => moduleConfig, updateAndSave: async (key, value) => { // 自动为路径添加模块ID前缀,防止跨模块修改 const path = `modules.${id}.${key}`; await this.#services.configManager.updateAndSave(path, value); // 触发全局事件,以保持UI同步等 this.#services.eventBus.emit('config-updated', { path, value, newConfig: this.#services.configManager.getConfig() }); } }; const moduleServicesFacade = { ...this.#services, // 继承所有安全的服务 configManager: configManagerFacade, // 覆盖为安全的门面 moduleManager: null, // 彻底隐藏ModuleManager,模块不应该直接操作它 }; // 使用安全的门面对象来实例化模块 const moduleInstance = new ModuleClass(moduleServicesFacade, moduleConfig); this.#auditor?.auditStart(id); moduleInstance.onEnable(matchContext); this.#auditor?.auditEnd(); this.#activeModuleInstances.set(id, moduleInstance); // 如果模块需要UI守护,则注册并触发首次渲染 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); } } /** * 封装的禁用模块的逻辑 */ #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); moduleInstance.onDisable(); 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 = () => { 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()) { this.#revalidateModuleState(id, currentConfig); } } /** * 重新评估一个模块的最终状态(启用/禁用),并执行相应操作。 * 这是模块生命周期管理的唯一决策点。 * @param {string} id - 模块ID * @param {object} config - 当前的全局配置 * @private */ #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) { this.#enableModule(id, ModuleClass, config, matchResult); } else if (!shouldBeActive && isActiveNow) { this.#disableModule(id); } } } /** * @class UIManager - UI管理器 * * 负责创建、管理和销毁UI组件,如下方的设置面板。 */ class UIManager { #appName = 'CaliberApp'; #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.#sanitizer = services.framework.sanitizer; this.#defineSettingsPanelComponent(this.#sanitizer); } /** * 根据最终配置决定是否显示设置面板的触发按钮 * @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('settings-panel'); const modules = this.#services.moduleManager.getAllRegisteredModules(); this.#panelElement.setData( 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.'); } /** * 定义 <settings-panel> Web Component */ #defineSettingsPanelComponent(sanitizer) { if (customElements.get('settings-panel')) return; const APP_NAME = this.#appName; class SettingsPanel extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._isOpen = false; 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(modules, config, configManager, eventBus, hostWindow) { this._modules = modules; this._config = config; this._configManager = configManager; this._eventBus = eventBus; this._hostWindow = hostWindow; 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 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: 12px 0 0 12px; 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: 0; bottom: 50px; } .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: -100%; width: 360px; height: 100%; display: flex; flex-direction: column; transition: right 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 { right: 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; } /* --- 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>${APP_NAME} 设置</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; 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}"`; } html += ` <div class="form-group sub-item" ${dataAttrs.trim()}> <div class="sub-item-info"> <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': input.value = value; break; } }); } } customElements.define('settings-panel', 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) { this.#observer.observe(this.#hostDocument.documentElement, { 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(); constructor(logger, hostWindow, SAFE_APP_NAME) { this.#logger = logger.createTaggedLogger('Auditor', { backgroundColor: '#9C27B0' }); this.#hostWindow = hostWindow; this.#patchGlobalApis(SAFE_APP_NAME); 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) { // 全局查找并删除,因为 unregister 可能在模块上下文之外被调用 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) { leaksFound += resources.events.size; resources.events.forEach((types, target) => { 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(), intervals: new Set(), schedulers: new Set() }); } return this.#trackedResources.get(moduleId); } #patchGlobalApis(SAFE_APP_NAME) { this.#originalAddEventListener = EventTarget.prototype.addEventListener; this.#originalRemoveEventListener = EventTarget.prototype.removeEventListener; this.#originalSetInterval = this.#hostWindow.setInterval; this.#originalClearInterval = this.#hostWindow.clearInterval; const self = this; const namespace = `__CALIBER_${SAFE_APP_NAME}`; const responseEventName = `${namespace}_RESPONSE`; // --- Patch addEventListener --- EventTarget.prototype.addEventListener = function (type, listener, options) { if (this === self.#hostWindow.document && type === responseEventName) { // 直接调用原始方法,不进行任何追踪 return self.#originalAddEventListener.call(this, type, listener, options); } if (self.#activeModuleId) { const resources = self.#initializeTracking(self.#activeModuleId); if (!resources.events.has(this)) { resources.events.set(this, new Set()); } resources.events.get(this).add(type); } return self.#originalAddEventListener.call(this, type, listener, options); }; // --- Patch removeEventListener --- EventTarget.prototype.removeEventListener = function (type, listener, options) { for (const resources of self.#trackedResources.values()) { if (resources.events.has(this)) { const types = resources.events.get(this); types.delete(type); if (types.size === 0) { resources.events.delete(this); } break; } } return self.#originalRemoveEventListener.call(this, type, listener, options); }; // --- Patch setInterval --- this.#hostWindow.setInterval = function (handler, timeout) { const intervalId = self.#originalSetInterval.call(this, handler, timeout); if (self.#activeModuleId) { const resources = self.#initializeTracking(self.#activeModuleId); resources.intervals.add(intervalId); } return intervalId; }; // --- Patch clearInterval --- this.#hostWindow.clearInterval = function (id) { // 全局查找并删除,因为 clearInterval 可能在模块上下文之外被调用 for (const resources of self.#trackedResources.values()) { if (resources.intervals.has(id)) { resources.intervals.delete(id); break; } } return self.#originalClearInterval.call(this, 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 * [上下文创建器] 从应用名称派生出所有需要的上下文状态。 * @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 }; _CaliberInternals.patchHistoryForNavigation(eventBus, hostWindow); 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); } }); } } }; }, /** * 代理 history API 以感知SPA导航。 (配置器职责) * @param {object} bus - 事件总线实例。 */ patchHistoryForNavigation: (bus, hostWindow) => { const history = hostWindow.history; const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function (...args) { originalPushState.apply(this, args); bus.emit('navigate'); }; history.replaceState = function (...args) { originalReplaceState.apply(this, args); bus.emit('navigate'); }; hostWindow.addEventListener('popstate', () => bus.emit('navigate')); }, /** * @private * [服务工厂] 创建一个通用的DOM净化与安全注入服务。 * 这是框架中所有CSP/Trusted-Types对抗策略的唯一来源。 * @param {object} logger - 用于报告错误的 logger 实例。 * @returns {object} DOMSanitizer 服务实例。 */ createDOMSanitizer: (logger) => { let _policy = undefined; // 使用闭包缓存 Trusted Types 策略 const _getPolicy = () => { if (_policy === undefined) { _policy = null; if (window.trustedTypes && window.trustedTypes.createPolicy) { try { _policy = window.trustedTypes.createPolicy('CaliberUniversalPolicy#html', { 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; }, /** * 网络请求拦截器服务。 * 创建一个通用的拦截器服务,它通过安全的脚本注入来代理原生fetch,并使用CustomEvent将响应数据传回沙箱。 * * @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 patchFlag = `${namespace}_PATCHED`; const hooksRegistry = `${namespace}_HOOKS`; 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; }; 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 { if (config.method && config.method.toUpperCase() !== 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); }, /** * 添加或更新一个网络请求钩子。 * @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; } const pathJson = JSON.stringify(finalPath); const injectionCode = ` (() => { const doLog = ${isDebug}; if (!window['${patchFlag}']) { window['${patchFlag}'] = true; window['${hooksRegistry}'] = {}; const originalFetch = window.fetch; const RESPONSE_EVENT_NAME = '${responseEventName}'; const executeHooks = (node, url, config, currentPath = []) => { let matchedPath = null; if (typeof node === 'object' && node !== null) { for (const key in node) { if (Object.prototype.hasOwnProperty.call(node, key)) { const newPath = [...currentPath, key]; const hookResult = executeHooks(node[key], url, config, newPath); url = hookResult.url; config = hookResult.config; if (hookResult.matchedPath) { matchedPath = hookResult.matchedPath; break; } } } } else if (typeof node === 'function') { try { const result = node(url, config); if (result && result.url && result.config) { url = result.url; config = result.config; matchedPath = currentPath; } } catch (e) { if (doLog) console.error('[Caliber Hook Error]', e); } } return { url, config, matchedPath }; }; window.fetch = async function(...args) { let resource = args[0], config = args[1] || {}, url = new URL(String(resource), window.location.origin).toString(); const { url: finalUrl, config: finalConfig, matchedPath } = executeHooks(window['${hooksRegistry}'], url, config); args[0] = finalUrl; args[1] = finalConfig; const response = await originalFetch.apply(this, args); if (matchedPath) { const responseClone = response.clone(); responseClone.text().then(text => { let responseData; try { responseData = JSON.parse(text); } catch (e) { responseData = text; } const event = new CustomEvent(RESPONSE_EVENT_NAME, { detail: { path: matchedPath, responseData } }); document.dispatchEvent(event); }).catch(e => { if (doLog) console.warn('[Caliber Interceptor] Could not read response body for hooked request.', e); }); } return response; }; if (doLog) console.warn('[Caliber] Injected fetch interceptor is active.'); } const setByPath = (obj, p, val) => { const last = p.pop(); let node = obj; p.forEach(k => { node = (node[k] = (typeof node[k] === 'object' && node[k] !== null) ? node[k] : {}); }); node[last] = val; }; setByPath(window['${hooksRegistry}'], ${pathJson}, (${hookFunctionString})); if (${isDebug}) console.log(\`[Caliber] Hook at path [${finalPath.join('/')}] added/updated. Awaits response: ${awaitsResponse}\`); })(); `; sanitizer.injectScript(hostDocument, injectionCode); }, /** * 从注入的钩子注册表中移除一个钩子或分支。 * @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 removalCode = ` (() => { const registry = window['${hooksRegistry}']; if (!registry) return; const path = ${pathJson}; let parent = registry; for (let i = 0; i < path.length - 1; i++) { if (typeof parent[path[i]] === 'undefined') return; parent = parent[path[i]]; } delete parent[path[path.length - 1]]; if (${isDebug}) console.log(\`[Caliber] Hook or branch 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); } }; 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) => ({ _addedStyles: new Map(), add(cssString, id) { const styleElement = sanitizer.injectStyle(hostDocument, cssString, id); 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) => { const requestId = `req_${Math.random().toString(36).substr(2, 9)}`; const responseEventName = `${namespace}_RESPONSE_${requestId}`; const handleResponse = (event) => { 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.framework] - (可选) 用于微调 Caliber 框架内部行为的配置。 * @param {number} [options.framework.domProcessorBatchSize=20] - (可选) 设置 DomBatchProcessor 在每个渲染帧中处理的最大任务数。 * @returns {Promise<void>} */ async function createApp(options) { const { appName, modules, services, isDebug = false, settingsPanelEnabled = true } = options || {}; // 初始化基础环境 const loggerFactory = new LoggerService(isDebug); const mainLogger = loggerFactory.createMainLogger(appName || 'CaliberApp'); const hostWindow = (services.hostWindow || (('unsafeWindow' in window) ? unsafeWindow : window)); 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); // 优先使用用户提供的,否则使用框架内置的原生适配器 const finalServices = { storage: services.storage || _CaliberInternals._nativeBrowserAdapters.storage(`CALIBER_STORAGE_${appName}`), command: _CaliberInternals._nativeBrowserAdapters.command, style: services.style || _CaliberInternals._nativeBrowserAdapters.style(sanitizer, hostDocument), ...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 } }, }; 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 基类,以便应用脚本可以继承它 }; })(); // #endregion // #region ================================ 功能模块定义 (Feature Modules) ================================ /** * 日/夜间模式切换模块 */ class ThemeSwitcherModule extends Caliber.Module { id = 'themeSwitcher'; name = '夜间模式'; description = '提供日间/夜间模式切换。'; defaultConfig = { enabled: true, themeMode: { label: '暗黑模式', type: 'select', value: 'auto', description: '“自动”将根据您的系统设置来切换模式。', options: [ { label: '停用', value: 'off' }, { label: '启用', value: 'on' }, { label: '自动', value: 'auto' }, ] }, }; #DARK_THEME_CSS = ` html.theme-dark { & { --color-primary-white--value: 28, 28, 30; --color-primary-black--value: 229, 229, 234; --color-font-1--value: 229, 229, 234; --color-font-2--value: 152, 152, 157; --color-font-3--value: 110, 110, 115; --color-font-4--value: 80, 80, 85; --color-background-1--value: 18, 18, 18; --color-background-2--value: 28, 28, 30; --color-background-3--value: 44, 44, 46; --color-border-1--value: 58, 58, 60; --color-border-2--value: 44, 44, 46; --color-font-5--value: 50, 150, 255; --color-border-3--value: 28, 28, 30; --color-background-4--value: 220, 220, 225; --color-background-5--value: rgba(229, 229, 234, 0.1); --color-background-hover: rgba(255, 255, 255, 0.08); --color-background-hover-1: rgb(var(--color-background-3--value)); --color-background-hover-2: rgb(var(--color-background-2--value)); --color-background-hover-3: rgba(255, 255, 255, 0.04); --color-primary-red--value: 255, 80, 95; --color-primary-blue--value: 20, 130, 255; --color-primary-green--value: 70, 200, 90; --color-gradient-red-1: #d94851; --color-gradient-red-2: #c22b42; --color-gradient-orange-1: #cc8100; --color-gradient-orange-2: #b86436; --color-gradient-purple-1: #c558cc; --color-gradient-purple-2: #a22b42; --color-gradient-blue-1: #4292cc; --color-gradient-blue-2: #1574cd; --color-gradient-green-1: #72a920; --color-gradient-green-2: #48a616; --color-game-price-low: #4CAF50; --color-game-price-new-low: #81C784; --color-game-price-super-low: linear-gradient(72deg, #00bfa5 24.1%, #00c853 75.9%); --color-platform-steam: #2e4763; --box-shadow-1: 0 4px 20px rgba(0, 0, 0, 0.5); --color-background-subtle--value: 58, 58, 60; --color-background-subtle: rgb(var(--color-background-subtle--value)); --color-gradient-black-1-1: #5a5f64; --color-gradient-black-1-2: #8e9499; color-scheme: dark; } img, video, [style*="background-image"] { filter: brightness(0.85) contrast(1.05); } .hb-cpt__loading.circle, .game-detail-page-topic .page-topic-header, .game-detail-section-footer, .game-detail-section-data .game-data, .game-info, .hb-cpt-login-mask .hb-cpt-login .left-box, .phone-login-wrapper, .hb-cpt__link-game-card { background-color: var(--color-primary-white) !important; } .hb-website__post-btn, .hot-topic__look, .user-profile-user-head .info-box .name-box .name, .hb-page__user-profile .user-profile-wrapper .bbs-info .bbs-info-item .value, .game-detail-section-data .game-data .row-1 .game-name .name, .game-rank__game-card .game-info .line-1, .phone-login-wrapper .user-info-box .row .prefix label { color: var(--color-primary-black) !important; } .hb-cpt__link-game-card p, button.link-reply__menu-btn, button { color: var(--color-font-1) !important; } .hb-bbs-home .hb-bbs-home__splitline::after { border: 1px solid var(--color-border-1); width: calc(100% + 30px); background-color: var(--color-border-1); } .hb-bbs-home>.bbs-home__topic-list-wrapper.hb-bbs-home__splitline::after { left: 0; width: calc(100% - 2px); } .game-detail-section-comment, .game-detail-section-score, .game-detail-section-similar-games, .phone-login-wrapper .user-info-box .row .prefix label span { border-top-color: var(--color-border-1); } .hb-page__user-profile .user-profile-wrapper { border-bottom-color: var(--color-border-1); } .game-detail-comment-item:after, .game-detail-section-footer:after, .search-result__space { background-color: var(--color-border-1) !important; } .hb-header-logo__image { filter: invert(1) brightness(2) contrast(100); } .hb-cpt__pagination--right { background: linear-gradient(270deg, var(--color-background-2) 0%, var(--color-background-2) 50%, rgba(var(--color-background-2--value), 0) 100%); } .hb-cpt__pagination--left { background: linear-gradient(90deg, var(--color-background-2) 0%, var(--color-background-2) 50%, rgba(var(--color-background-2--value), 0) 100%); } .game-detail-section-data .game-data .btn-see-all, .hardware-performance { background-color: var(--color-background-3); } .game-detail-section-data .game-data .row-3 .data-list .data-item { background-color: var(--color-background-3); &::before { border-color: var(--color-background-3); } } .bbs-content__game-card .hb-cpt__link-game-card { border:1px solid var(--color-background-3); &::after { content: initial; } } .game-detail-page-detail .section-title .title, .game-detail-section-data .game-info .game-awards .award-item .award-info .award-detail, .game-detail-section-data .game-info .menu-list .menu-item p, .com-text { color: var(--color-primary-black); } .game-detail-section-data .game-info .about-game, .game-detail-comment-item .description, .game-detail-section-score .publish-score-wrapper .publish-desc>p, .hb-header-logo__desc { color: var(--color-font-1); } .game-detail-section-data .game-data .row-1 .score-wrapper .comment span { color: var(--color-font-4); } .game-detail-section-data .game-info .hardware-performance { background: linear-gradient(var(--angle-gradient--value), var(--color-gradient-black-1-2), var(--color-gradient-black-1-1)); } .game-detail-comment-item .description .btn-all { background-color: var(--color-background-3); color: var(--color-font-1); &::after { background: linear-gradient(270deg, var(--color-background-3), transparent); } } .game-detail-comment-item .tools .item, .hb-game-comment .game-comment__content .game-comment__content-tools .item, .hb-page__user-profile .post-link-wrapper .tab-list--wrapper .link-tab-list .tab-active-block { background-color: var(--color-background-4); } .user-profile-user-head .info-box .name-box .detail-info { background-color: var(--color-gradient-black-1-2); } .search-pull__default-page .search__hot-rank .hot-rank__list .hot-rank__list-item:hover, .game-detail-page-topic .page-topic-link-list .hb-bbs-home__splitline:after { background-color: var(--color-background-3); } .hb-layout__fake-frame-left--top svg, .hb-layout__fake-frame-left--bottom svg { display: none; } .game-detail-comment-item:after { left: 0; width: 100%; } } `; #mediaQuery = null; #themeChangeListener = null; #styleId = 'caliber-theme-switcher-style'; onEnable() { this._services.style.add(this.#DARK_THEME_CSS, this.#styleId); this.#mediaQuery = this._hostWindow.matchMedia('(prefers-color-scheme: dark)'); this.#themeChangeListener = this.applyTheme; this.#mediaQuery.addEventListener('change', this.#themeChangeListener); this.applyTheme(); } onDisable() { this._services.style.remove(this.#styleId); if (this.#mediaQuery && this.#themeChangeListener) { this.#mediaQuery.removeEventListener('change', this.#themeChangeListener); } this._hostDocument.documentElement.classList.remove('theme-dark'); this.#mediaQuery = null; this.#themeChangeListener = null; } applyTheme = () => { const isSystemDark = this.#mediaQuery.matches; const rootElement = this._hostDocument.documentElement; switch (this._config.themeMode) { case 'on': rootElement.classList.add('theme-dark'); break; case 'off': rootElement.classList.remove('theme-dark'); break; case 'auto': default: if (isSystemDark) { rootElement.classList.add('theme-dark'); } else { rootElement.classList.remove('theme-dark'); } break; } } onConfigChange(key, newValue, oldValue) { this._logger.log(`Module '${this.id}' config '${key}' changed from '${oldValue}' to '${newValue}'.`); if (key === 'themeMode') { this.applyTheme(); } } } /** * 返回按钮逻辑修正模块 */ class BackButtonFixModule extends Caliber.Module { id = 'backButtonFix'; name = '返回按钮修复'; description = '修正帖子页面的返回按钮行为。'; defaultConfig = { enabled: false, }; #isInIframe = false; #boundHandleClick = this.#handlelBackButton.bind(this); onEnable() { this.#isInIframe = (this._hostWindow.self !== this._hostWindow.top); this._hostDocument.addEventListener('click', this.#boundHandleClick, true); } onDisable() { this._hostDocument.removeEventListener('click', this.#boundHandleClick, true); } #handlelBackButton(event) { const backButton = event.target.closest('.page-header__back-btn'); if (!backButton) return; if (this.#isInIframe) { this.#handleIframeBackButton(event, backButton); } else if (this._utils.checkMatch('/app/bbs/link')) { this.#handleStandardBackButton(event, backButton); } } #handleStandardBackButton(event, backButton) { if (backButton.dataset.override) return; const originalUrl = this._hostWindow.location.href; event.stopImmediatePropagation(); backButton.dataset.override = 'true'; this._hostWindow.history.back(); const checkNavigation = () => { if (this._hostWindow.location.href === originalUrl) { this._logger.warn('Navigation failed, releasing event to default handler'); backButton.dispatchEvent(new Event('click', { bubbles: true })); } }; setTimeout(checkNavigation, 100); } /** * 为 (窗口模式) 场景设计的。 */ async #handleIframeBackButton(event) { event.preventDefault(); event.stopImmediatePropagation(); try { const getPreviousUrlCode = ` (() => { const router = useNuxtApp().$router; return router.options.history.state.back || null; })() `; const previousUrl = await this._executor.execute(getPreviousUrlCode); if (previousUrl) { this._logger.log(`Found previous URL in router state: "${previousUrl}". Navigating...`); const navigateCode = `(() => { useNuxtApp().$router.push('${previousUrl}'); })()`; await this._executor.execute(navigateCode); } else { this._logger.warn('No previous URL in router. Falling back to community home.'); const fallbackNavigateCode = `(() => { useNuxtApp().$router.push('/app/bbs/home'); })()`; await this._executor.execute(fallbackNavigateCode); } } catch (e) { this._logger.error('Failed to interact with Nuxt Router. Falling back to simple location change.', e); this._hostWindow.location.href = 'https://www.xiaoheihe.cn/app/bbs/home'; } } } /** * 新标签页打开帖子模块 */ class NewTabPageModule extends Caliber.Module { id = 'newTabPage'; name = '新标签页打开帖子'; description = '社区中的feeds流链接将会在新的浏览器标签页中打开。'; defaultConfig = { enabled: false, }; #schedulerTaskId = null; #rootSelector = 'div.hb-cpt__scroll-list'; #selector = 'a.hb-cpt__bbs-content'; onEnable() { this.#schedulerTaskId = this._scheduler.register( this.#selector, this.#processLink, { root: this.#rootSelector, processExisting: true } ); this._logger.log(`Module '${this.id}' enabled. Watcher is active for existing and new links.`); } onDisable() { if (this.#schedulerTaskId) { this._scheduler.unregister(this.#schedulerTaskId); this.#schedulerTaskId = null; } } #processLink = (linkElement) => { if (!linkElement.dataset.newTabPageTarget) { linkElement.dataset.newTabPageTarget = true; linkElement.target = '_blank'; } } } /** * 快速跳转到评论区模块 */ class QuickJumpToCommentsModule extends Caliber.Module { id = 'quickJumpToComments'; name = '快速跳转到评论区'; description = '在帖子顶部的操作栏中添加一个按钮,可快速跳转到评论区。'; match = '/app/bbs/link'; defaultConfig = { enabled: true, }; uiGuard = { target: 'div.page-header__other-trans .page-header--right', component: '#caliber-quick-jump-btn' }; #commentsSelector = 'div.link-comment'; #componentTag = 'quick-jump-button'; onEnable() { this._logger.log(`Module '${this.id}' enabled.`); this.#defineButtonComponent(this._sanitizer); } onDisable() { this._logger.log(`Module '${this.id}' disabled.`); } onRender(targetElement) { const commentsSection = this._hostDocument.querySelector(this.#commentsSelector); if (!commentsSection) { this._logger.log('Comments section not found, deferring render.'); return; } this._logger.log('Guardian triggered render for QuickJumpToComments.'); targetElement.style.display = 'flex'; targetElement.style.alignItems = 'center'; targetElement.style.gap = '12px'; const buttonElement = this._hostDocument.createElement(this.#componentTag); buttonElement.id = this.uiGuard.component.substring(1); buttonElement.addEventListener('jump-click', this.#handleClick); targetElement.prepend(buttonElement); } onCleanup() { const button = this._hostDocument.querySelector(this.uiGuard.component); if (button) { button.removeEventListener('jump-click', this.#handleClick); button.remove(); } } #handleClick = () => { const targetElement = this._hostDocument.querySelector(this.#commentsSelector); if (targetElement) { this._hostWindow.scrollTo({ top: targetElement.offsetTop, behavior: 'smooth' }); } }; #defineButtonComponent = (sanitizer) => { if (customElements.get(this.#componentTag)) return; class QuickJumpButton extends HTMLElement { _buttonElement = null; _clickHandler = null; constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { const html = ` <style> button { background: none; border: none; padding: 0; margin: 0; font: inherit; color: inherit; cursor: pointer; outline: inherit; padding: 4px 8px; border: 1px solid var(--color-border-1, #e0e0e0); border-radius: 4px; font-size: 12px; color: var(--color-font-2, #757575); background-color: transparent; transition: background-color 0.2s, color 0.2s; user-select: none; } button:hover { background-color: var(--color-background-hover, #f5f5f5); color: var(--color-font-1, #212121); } </style> <button>直达评论</button> `; sanitizer.setInnerHTML(this.shadowRoot, html); this._buttonElement = this.shadowRoot.querySelector('button'); this._clickHandler = () => this.dispatchEvent(new CustomEvent('jump-click', { bubbles: true, composed: true })); this._buttonElement.addEventListener('click', this._clickHandler); } disconnectedCallback() { if (this._buttonElement && this._clickHandler) { this._buttonElement.removeEventListener('click', this._clickHandler); } } } customElements.define(this.#componentTag, QuickJumpButton); }; } /** * Steam商店直达模块 */ class SteamDirectLinkModule extends Caliber.Module { id = 'steamDirectLink'; name = 'Steam商店直达'; description = '在PC游戏主题页,提供直达Steam商店和SteamDB页面的按钮。'; match = '/app/topic/game/pc/:gameId'; defaultConfig = { enabled: true, }; uiGuard = { target: 'div.hb-cpt__pagination > .hb-cpt__pagination-outer', component: '#caliber-steam-links-container' }; #buttonComponentTag = 'caliber-link-button'; #originalContainerStyles = null; #gameId = null; onEnable(context) { this._logger.log(`Module '${this.id}' activated on a matched page.`); this.#gameId = context.params.gameId || null; this.#defineButtonComponent(this._sanitizer); } onDisable() { this._logger.log(`Module '${this.id}' deactivated.`); } onRender(targetElement) { if (!this.#gameId) return; this.#originalContainerStyles = { display: targetElement.style.display, justifyContent: targetElement.style.justifyContent, alignItems: targetElement.style.alignItems, overflow: targetElement.style.overflow, }; targetElement.style.display = 'flex'; targetElement.style.justifyContent = 'space-between'; targetElement.style.alignItems = 'center'; targetElement.style.overflow = 'initial'; const container = this._hostDocument.createElement('div'); container.id = this.uiGuard.component.substring(1); container.style.display = 'flex'; container.style.gap = '8px'; const createButton = ({ href, text, variant, iconSvg = null }) => { const button = this._hostDocument.createElement(this.#buttonComponentTag); button.setAttribute('href', href || ''); button.setAttribute('text', text || ''); button.setAttribute('variant', variant || ''); if (iconSvg) { button.setAttribute('icon-svg', iconSvg); } return button; }; const steamLink = `https://store.steampowered.com/app/${this.#gameId}`; const steamDBLink = `https://steamdb.info/app/${this.#gameId}`; const steamIconSvg = `<svg t="1755831740195" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5073" width="32" height="32"><path d="M1008 512c0 274-222.4 496-496.8 496-227.6 0-419.2-152.6-478-360.8l190.4 78.6c12.8 64.2 69.8 112.8 137.8 112.8 78.4 0 143.8-64.8 140.4-147l169-120.4c104.2 2.6 191.6-81.8 191.6-187 0-103.2-84-187-187.4-187s-187.4 84-187.4 187v2.4L369.2 558c-31-1.8-61.4 6.8-87 24.2L16 472.2C36.4 216.8 250.2 16 511.2 16 785.6 16 1008 238 1008 512zM327.4 768.6l-61-25.2a105.58 105.58 0 0 0 54.4 51.6c53.8 22.4 115.6-3.2 138-56.8 10.8-26 11-54.6 0.2-80.6-10.8-26-31-46.4-57-57.2-25.8-10.8-53.4-10.4-77.8-1.2l63 26c39.6 16.4 58.4 61.8 41.8 101.4-16.6 39.8-62 58.4-101.6 42z m347.6-259.8c-68.8 0-124.8-56-124.8-124.6s56-124.6 124.8-124.6 124.8 56 124.8 124.6-55.8 124.6-124.8 124.6z m0.2-31.2c51.8 0 93.8-42 93.8-93.6 0-51.8-42-93.6-93.8-93.6s-93.8 42-93.8 93.6c0.2 51.6 42.2 93.6 93.8 93.6z" fill="" p-id="5074"></path></svg>`; const steamDBIconSvg = `<svg width="30" height="30" viewBox="0 0 128 128" class="octicon octicon-steamdb" aria-hidden="true"><path fill-rule="evenodd" d="M63.9 0C30.5 0 3.1 11.9.1 27.1l35.6 6.7c2.9-.9 6.2-1.3 9.6-1.3l16.7-10c-.2-2.5 1.3-5.1 4.7-7.2 4.8-3.1 12.3-4.8 19.9-4.8 5.2-.1 10.5.7 15 2.2 11.2 3.8 13.7 11.1 5.7 16.3-5.1 3.3-13.3 5-21.4 4.8l-22 7.9c-.2 1.6-1.3 3.1-3.4 4.5-5.9 3.8-17.4 4.7-25.6 1.9-3.6-1.2-6-3-7-4.8L2.5 38.4c2.3 3.6 6 6.9 10.8 9.8C5 53 0 59 0 65.5c0 6.4 4.8 12.3 12.9 17.1C4.8 87.3 0 93.2 0 99.6 0 115.3 28.6 128 64 128c35.3 0 64-12.7 64-28.4 0-6.4-4.8-12.3-12.9-17 8.1-4.8 12.9-10.7 12.9-17.1 0-6.5-5-12.6-13.4-17.4 8.3-5.1 13.3-11.4 13.3-18.2 0-16.5-28.7-29.9-64-29.9zm22.8 14.2c-5.2.1-10.2 1.2-13.4 3.3-5.5 3.6-3.8 8.5 3.8 11.1 7.6 2.6 18.1 1.8 23.6-1.8s3.8-8.5-3.8-11c-3.1-1-6.7-1.5-10.2-1.5zm.3 1.7c7.4 0 13.3 2.8 13.3 6.2 0 3.4-5.9 6.2-13.3 6.2s-13.3-2.8-13.3-6.2c0-3.4 5.9-6.2 13.3-6.2zM45.3 34.4c-1.6.1-3.1.2-4.6.4l9.1 1.7a10.8 5 0 1 1-8.1 9.3l-8.9-1.7c1 .9 2.4 1.7 4.3 2.4 6.4 2.2 15.4 1.5 20-1.5s3.2-7.2-3.2-9.3c-2.6-.9-5.7-1.3-8.6-1.3zM109 51v9.3c0 11-20.2 19.9-45 19.9-24.9 0-45-8.9-45-19.9v-9.2c11.5 5.3 27.4 8.6 44.9 8.6 17.6 0 33.6-3.3 45.2-8.7zm0 34.6v8.8c0 11-20.2 19.9-45 19.9-24.9 0-45-8.9-45-19.9v-8.8c11.6 5.1 27.4 8.2 45 8.2s33.5-3.1 45-8.2z"></path></svg>`; container.appendChild(createButton({ href: steamLink, // text: 'Steam', variant: 'steam', iconSvg: steamIconSvg })); container.appendChild(createButton({ href: steamDBLink, // text: 'SteamDB', variant: 'steamdb', iconSvg: steamDBIconSvg })); targetElement.appendChild(container); } onCleanup() { const container = this._hostDocument.querySelector(this.uiGuard.component); if (container) { const parent = container.parentElement; container.remove(); if (parent && this.#originalContainerStyles) { parent.style.display = this.#originalContainerStyles.display; parent.style.justifyContent = this.#originalContainerStyles.justifyContent; parent.style.alignItems = this.#originalContainerStyles.alignItems; } } this.#originalContainerStyles = null; } #defineButtonComponent = (sanitizer) => { if (customElements.get(this.#buttonComponentTag)) return; class CaliberLinkButton extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } static get observedAttributes() { return ['href', 'text', 'variant', 'icon-svg']; } connectedCallback() { const html = ` <style> :host { display: inline-block; } .btn { position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 2px; font-size: 13px; font-weight: 500; border: none; color: #e0e0e0; background-color: #3a3a3a; box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.15); text-decoration: none; user-select: none; transform: translateY(0); transition: background-color 0.1s ease, box-shadow 0.2s ease, transform 0.1s ease; } .btn:hover { cursor: pointer; background-color: #555555; } .btn:active { transform: translateY(1px); box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3); } .btn .icon { display: inline-flex; align-items: center; width: 16px; height: 16px; } .btn .icon svg { width: 100%; height: 100%; fill: currentColor; } .btn .text:empty { display: none; } .btn--steam { color: #ffffff; background-color: #1a9fff; box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.32); } .btn--steam:hover { background-color: #00bbff; } .btn::before, .btn::after { position: absolute; left: 50%; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 0.2s ease-out, transform 0.2s ease-out; } .btn::after { content: attr(data-tooltip); top: calc(100% + 8px); padding: 5px 10px; border-radius: 3px; font-size: 12px; font-weight: 400; color: #e0e0e0; background-color: #2c2c2c; box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.25); white-space: nowrap; transform: translateX(calc(-100% + 20px)) translateY(-5px); } .btn::before { content: ''; top: calc(100% + -2px); border: 5px solid transparent; border-bottom-color: #2c2c2c; transform: translateX(-50%) translateY(-5px); } .btn:hover::before { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); } .btn:hover::after { opacity: 1; visibility: visible; transform: translateX(calc(-100% + 20px)) translateY(0); } </style> <a href="#" target="_blank" rel="noopener noreferrer" class="btn"> <span class="icon"></span> <span class="text"></span> </a> `; sanitizer.setInnerHTML(this.shadowRoot, html); this._render(); } attributeChangedCallback() { this._render(); } _render() { const link = this.shadowRoot.querySelector('a'); const iconContainer = this.shadowRoot.querySelector('.icon'); const textContainer = this.shadowRoot.querySelector('.text'); if (!link) return; const href = this.getAttribute('href') || '#'; const text = this.getAttribute('text') || ''; const variant = this.getAttribute('variant'); const iconSvg = this.getAttribute('icon-svg'); link.href = href; link.className = `btn btn--${variant}`; link.dataset.tooltip = `直达${(variant || '').toLocaleUpperCase()}`; textContainer.textContent = text; if (iconSvg) { sanitizer.setInnerHTML(iconContainer, iconSvg); iconContainer.style.display = 'inline-flex'; } else { iconContainer.innerHTML = ''; iconContainer.style.display = 'none'; } } } customElements.define(this.#buttonComponentTag, CaliberLinkButton); }; } /** * 首页帖子动态增强模块 */ class FeedsEnhancementModule extends Caliber.Module { id = 'feedsEnhancement'; name = '首页帖子动态增强'; description = '为首页Feeds流中的帖子卡片动态添加其所属的游戏社区标签,发帖时间等。'; match = '/app/bbs/home'; defaultConfig = { enabled: true, }; #postDataMap = new Map(); #schedulerTaskId = null; #rootSelector = 'div.bbs-home__content-list'; #rootSelectorElement = null; #postCardSelector = 'a.hb-cpt__bbs-content.hb-cpt__bbs-list-content.bbs-home__content-item'; #boundHandleResponse = this.#handleResponse.bind(this); #boundProcessPostCard = this.#processPostCard.bind(this); onEnable() { this._logger.log(`Module '${this.id}' enabled. Interceptor deployed.`); this._interceptor .target({ url: 'https://api.xiaoheihe.cn/bbs/app/feeds' }) .onResponse(this.#boundHandleResponse) .register(this.id); } onDisable() { this._logger.log(`Module '${this.id}' disabled. Cleaning up all resources.`); this._interceptor.removeHook(this.id); if (this.#schedulerTaskId) { this._scheduler.unregister(this.#schedulerTaskId); this._schedulerTaskId = null; } this.#postDataMap.clear(); } /** * 响应回调:处理API数据,并部署DOM改造任务。 */ #handleResponse(responseData) { if (!responseData?.result?.links) return; if (!this.#rootSelectorElement) { this.#rootSelectorElement = this._hostDocument.querySelector(this.#rootSelector); } let newPostsCached = 0; for (const post of responseData.result.links) { if (post.linkid) { const postId = post.linkid.toString(); this.#postDataMap.set(postId, post); newPostsCached++; } } if (newPostsCached === 0) return; this._logger.log(`Intelligence gathered for ${newPostsCached} new posts. Starting immediate processing.`); const unprocessedCards = this.#rootSelectorElement.querySelectorAll(`${this.#postCardSelector}:not([data-caliber-enhanced])`); if (unprocessedCards.length > 0) { unprocessedCards.forEach(card => this.#boundProcessPostCard(card)); } } /** * DOM处理回调:对匹配到的帖子卡片进行改造。 */ #processPostCard(postCardElement) { if (postCardElement.dataset.caliberEnhanced) return; const href = postCardElement.getAttribute('href'); const match = href?.match(/\/bbs\/link\/(\d+)/); const postId = match?.[1]; if (!postId) return; const postData = this.#postDataMap.get(postId); if (!postData) return; if (!Array.isArray(postData.topics) || typeof postData.create_at === 'undefined') { this._logger.log(`Skipping enhancement for post ${postId}: Data structure is non-standard (likely an ad or other content type).`); postCardElement.dataset.caliberEnhanced = 'true'; return; } const topics = postData.topics; const createTime = postData.create_at; try { const bottomLine = postCardElement.querySelector('.bbs-content__bottom-line'); if (!bottomLine) return; postCardElement.dataset.caliberEnhanced = 'true'; if (createTime) { const timeElement = document.createElement('span'); timeElement.textContent = this.#formatTimeAgo(createTime); timeElement.style.fontSize = '12px'; timeElement.style.color = 'var(--color-font-3, #9e9e9e)'; bottomLine.style.gap = '12px'; bottomLine.prepend(timeElement); } if (topics.length > 0) { const primaryTopic = topics[0]; if (primaryTopic && primaryTopic.topic_id && primaryTopic.name && primaryTopic.pic_url) { const tagButton = document.createElement('a'); tagButton.href = `/app/topic/link/${primaryTopic.topic_id}`; // tagButton.target = '_blank'; tagButton.className = 'hb-cpt__content-tag big link-tags__tag-item'; tagButton.style.backgroundColor = 'var(--color-background-3)'; tagButton.style.color = 'var(--color-font-2)'; tagButton.style.textDecoration = 'none'; tagButton.style.margin = '0'; tagButton.addEventListener('click', e => e.stopPropagation()); const tagIcon = document.createElement('img'); tagIcon.src = primaryTopic.pic_url; tagIcon.alt = primaryTopic.name; tagIcon.className = 'content-tag-icon'; const tagName = document.createElement('span'); tagName.className = 'content-tag-text'; tagName.textContent = primaryTopic.name; tagButton.appendChild(tagIcon); tagButton.appendChild(tagName); bottomLine.prepend(tagButton); } } // this._logger.log(`Enhanced post card for post ${postId}.`); } catch (e) { this._logger.error(`Failed to enhance post card for post ${postId}`, e); } } #formatTimeAgo = (unixTimestamp) => { const now = new Date(); const past = new Date(unixTimestamp * 1000); const diffInSeconds = Math.floor((now - past) / 1000); const MINUTE = 60; const HOUR = MINUTE * 60; const DAY = HOUR * 24; if (diffInSeconds < MINUTE) { return `${diffInSeconds}秒前`; } if (diffInSeconds < HOUR) { return `${Math.floor(diffInSeconds / MINUTE)}分钟前`; } if (diffInSeconds < DAY) { return `${Math.floor(diffInSeconds / HOUR)}小时前`; } if (diffInSeconds < DAY * 5) { return `${Math.floor(diffInSeconds / DAY)}天前`; } const year = past.getFullYear(); const month = (past.getMonth() + 1).toString().padStart(2, '0'); const day = past.getDate().toString().padStart(2, '0'); return `${year}-${month}-${day}`; }; } /** * 游戏/社区主题页增强模块 */ class TopicEnhancementModule extends Caliber.Module { id = 'topicEnhancement'; name = '游戏/社区页增强'; description = '为游戏社区主题页添加网页快捷入口、修正小程序按钮行为。'; match = ['/app/topic/link', '/app/topic/game']; defaultConfig = { enabled: true, enableWebviewTags: { label: '显示网页快捷入口', type: 'boolean', value: true, description: '在主题页顶部添加多个直达相关网页的快捷按钮。' }, enableMiniProgramFix: { label: '修正小程序按钮', type: 'boolean', value: true, description: '让侧边栏的小程序按钮直接打开。' } }; uiGuard = { target: 'div.topic-aside__topic-indicator', component: '.caliber-webview-wrapper' }; #webviewLinks = []; #miniProgramDataByName = new Map(); #miniProgramSchedulerId = null; #delegatedContainer = null; #miniProgramContainerSelector = 'div.aside-miniprogram__content'; #miniProgramButtonSelector = 'button.aside-miniprogram__item'; onEnable() { this._logger.log(`Module '${this.id}' enabled. Deploying interceptor.`); this._interceptor .target({ url: 'https://api.xiaoheihe.cn/bbs/app/topic/menu', match: this.match, }) .onResponse(this.#handleResponse.bind(this)) .register(this.id); } onDisable() { this._logger.log(`Module '${this.id}' disabled. Cleaning up all resources.`); this._interceptor.removeHook(this.id); if (this.#miniProgramSchedulerId) { this._scheduler.unregister(this.#miniProgramSchedulerId); this.#miniProgramSchedulerId = null; } this.#cleanupMiniProgramFeature(); } onCleanup() { const injectedWrapper = this._hostDocument.querySelector(this.uiGuard.component); if (injectedWrapper) { injectedWrapper.remove(); } } onRender(targetElement) { if (this._config.enableWebviewTags && this.#webviewLinks.length > 0) { this.#injectWebviewTags(targetElement); } } #handleResponse(responseData) { const result = responseData?.result; if (!result) return; this.#webviewLinks = this._config.enableWebviewTags ? this.#extractWebviewLinks(result.menu || []) : []; this.#miniProgramDataByName.clear(); if (this._config.enableMiniProgramFix) { (result.mini_programs || []).forEach(program => { if (program.name) this.#miniProgramDataByName.set(program.name, program); }); } this._logger.log(`Intelligence updated: ${this.#webviewLinks.length} webview links, ${this.#miniProgramDataByName.size} mini programs.`); if (this._config.enableMiniProgramFix && this.#miniProgramDataByName.size > 0) { if (this.#miniProgramSchedulerId) this._scheduler.unregister(this.#miniProgramSchedulerId); this.#miniProgramSchedulerId = this._scheduler.register( this.#miniProgramContainerSelector, (container) => this.#delegateMiniProgramClicks(container), { processExisting: true } ); } } #cleanupMiniProgramFeature() { if (this.#delegatedContainer) { this.#delegatedContainer.removeEventListener('click', this.#handleMiniProgramClick.bind(this), true); this.#delegatedContainer = null; } } // 保持不变 #extractWebviewLinks(menuData) { const links = []; menuData.forEach(item => { if (item.type === 'webview' && item.url) { links.push({ title: item.title, url: item.url, color: item.color, bg_config: item.bg_config }); } if (Array.isArray(item.filters)) { item.filters.forEach(filter => { if (filter.type === 'webview' && filter.url) { links.push({ title: filter.title, url: filter.url, color: filter.color || '#64696E', bg_config: filter.bg_config }); } }); } }); return links; } #injectWebviewTags(container) { const wrapper = this._hostDocument.createElement('div'); wrapper.className = this.uiGuard.component.substring(1); wrapper.style.cssText = 'display: flex; flex-wrap: wrap; gap: 8px; margin-block: 12px;'; this.#webviewLinks.forEach(({ title, url, color, bg_config }) => { const tag = this._hostDocument.createElement('a'); tag.href = url; tag.target = '_blank'; tag.rel = 'noopener noreferrer'; tag.textContent = title; let backgroundStyle; if (bg_config && bg_config.bg_color) { const normalizeColor = (c) => (c.startsWith('#') ? c : `#${c}`); const startColor = normalizeColor(bg_config.bg_color); const endColor = normalizeColor(color); backgroundStyle = `linear-gradient(90deg, ${startColor}, ${endColor})`; } else { backgroundStyle = color ? (color.startsWith('#') ? color : `#${color}`) : '#64696E'; } tag.style.cssText = ` display: inline-flex; align-items: center; justify-content: center; padding: 6px 14px; font-size: 13px; font-weight: 600; border-radius: 10px; text-decoration: none; color: rgba(255, 255, 255, 0.95); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); background: ${backgroundStyle}; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.15); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transform: translateY(0); cursor: pointer; user-select: none; `; tag.onmouseover = () => { tag.style.transform = 'translateY(-2px)'; tag.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.2)'; tag.style.filter = 'brightness(1.1)'; }; tag.onmouseout = () => { tag.style.transform = 'translateY(0)'; tag.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.15)'; tag.style.filter = 'brightness(1)'; }; wrapper.appendChild(tag); }); container.appendChild(wrapper); } #delegateMiniProgramClicks(container) { if (this.#delegatedContainer === container) return; if (this.#delegatedContainer) { this.#delegatedContainer.removeEventListener('click', this.#handleMiniProgramClick.bind(this), true); } this.#delegatedContainer = container; this.#delegatedContainer.addEventListener('click', this.#handleMiniProgramClick.bind(this), true); this._logger.log('Delegated click listener to a new mini program container.'); } #handleMiniProgramClick(event) { const button = event.target.closest(this.#miniProgramButtonSelector); if (!button) return; const nameElement = button.querySelector('.aside-miniprogram__item--name'); const buttonName = nameElement?.textContent?.trim(); if (!buttonName) return; const programData = this.#miniProgramDataByName.get(buttonName); if (!programData) return; const protoString = programData.proto; const jsonMatch = protoString.match(/(\{.*\})$/); if (!jsonMatch) return; try { const protoData = JSON.parse(jsonMatch[1]); const targetUrl = protoData.webview?.url; if (targetUrl) { event.preventDefault(); event.stopImmediatePropagation(); this._hostWindow.open(targetUrl, '_blank'); } } catch (e) { this._logger.error(`Failed to parse extracted JSON for "${buttonName}".`, e); } } } /** * 自定义社区导航模块 */ class CustomMenuModule extends Caliber.Module { id = 'customMenu'; name = '自定义社区导航'; description = '在侧边栏添加自定义的社区快捷入口,方便快速访问。'; defaultConfig = { enabled: false, topicIds: { label: '社区Topic ID列表', type: 'string', value: '', description: '请输入一个或多个社区的Topic ID,用英文逗号 (,) 分隔。' }, }; uiGuard = { target: 'div.hb-websit__left-section', component: 'caliber-custom-menu-container' }; #componentContainerTag = 'caliber-custom-menu-container'; #componentItemTag = 'caliber-custom-menu-item'; async onEnable() { this._logger.log(`Module '${this.id}' enabled. Initializing...`); this.#defineComponents(this._sanitizer); await this.#syncAndCacheTopics(); } onDisable() { this._logger.log(`Module '${this.id}' disabled.`); } async onConfigChange(key, newValue, oldValue) { if (key === 'topicIds' && newValue !== oldValue) { this._logger.log('Topic ID list changed. Re-syncing data...'); await this.#syncAndCacheTopics(); } } onRender(targetElement) { this._logger.log('Guardian triggered render for CustomMenu.'); const moduleConfig = this._config; const orderedIds = this.#parseAndSanitizeIds(moduleConfig.topicIds); const cachedTopics = moduleConfig.cachedTopics || {}; if (orderedIds.length === 0) { return; } const menuContainer = this._hostDocument.createElement(this.#componentContainerTag); orderedIds.forEach((id, index) => { const topicData = cachedTopics[id]; if (topicData && topicData.topic) { const item = this._hostDocument.createElement(this.#componentItemTag); item.setAttribute('data-topic', JSON.stringify(topicData.topic)); item.setAttribute('data-bg', JSON.stringify(topicData.bg_color || {})); item.setAttribute('animation-delay', `${index * 80}ms`); menuContainer.appendChild(item); } }); if (menuContainer.hasChildNodes()) { targetElement.appendChild(menuContainer); } } onCleanup() { const existingMenu = this._hostDocument.querySelector(this.uiGuard.component); if (existingMenu) { existingMenu.remove(); } } #parseAndSanitizeIds(userInput) { if (!userInput || typeof userInput !== 'string') { return []; } const isNumericString = (str) => /^\d+$/.test(str); return userInput .split(',') .map(id => id.trim()) .filter(id => id && isNumericString(id)); } async #syncAndCacheTopics() { const moduleConfig = this._config; const targetIds = this.#parseAndSanitizeIds(moduleConfig.topicIds); const cachedTopics = moduleConfig.cachedTopics || {}; const idsToFetch = targetIds.filter(id => !cachedTopics[id]); if (idsToFetch.length === 0) { if (targetIds.length > 0) this._logger.log('All required topic data is already cached.'); return; } this._logger.log(`Fetching data for ${idsToFetch.length} new topic(s):`, idsToFetch); const results = await Promise.all(idsToFetch.map(id => this.#fetchTopicData(id))); const newCache = { ...cachedTopics }; let updated = false; results.forEach((data, index) => { const id = idsToFetch[index]; if (data) { newCache[id] = data; updated = true; } }); if (updated) { await this._services.configManager.updateAndSave('cachedTopics', newCache); } } async #fetchTopicData(topicId) { try { const remoteCode = ` (async () => { return await useNuxtApp().$api('/bbs/app/topic/menu', { method: "GET", query: { topic_id: '${topicId}' }, credentials: "include", deep: false }); })() `; const responseData = await this._executor.execute(remoteCode); if (responseData?.status === 'ok' && responseData?.result?.topic) { return responseData.result; } this._logger.warn(`API did not return valid data for topic ID: ${topicId}. It might be an invalid ID.`); return null; } catch (error) { this._logger.error(`Error executing page-scope code for topic ID ${topicId}:`, error); return null; } } #defineComponents(sanitizer) { if (!customElements.get(this.#componentContainerTag)) { class MenuContainer extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); const styles = ` :host { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; padding-top: 16px; position: relative; } .title { font-size: 12px; color: var(--color-font-3, #9e9e9e); text-shadow: 1px 1px 24px #ffffff; padding: 0 12px; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 26px; } `; const html = `<style>${styles}</style><div class="title">快速导航</div><slot></slot>`; sanitizer.setInnerHTML(this.shadowRoot, html); } } customElements.define(this.#componentContainerTag, MenuContainer); } if (!customElements.get(this.#componentItemTag)) { class MenuItem extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { try { const rawTopic = this.getAttribute('data-topic'); const rawBg = this.getAttribute('data-bg'); if (!rawTopic || !rawBg) return; const topic = JSON.parse(rawTopic.replaceAll('"', '"')); const bg = JSON.parse(rawBg.replaceAll('"', '"')); const animationDelay = this.getAttribute('animation-delay') || '0s'; const bgColor = { start: bg.start || '#736E7D', end: bg.end || '#1B2025' }; const styles = ` @keyframes fadeInSlideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } :host { display: block; } .link { display: flex; align-items: center; padding: 8px 10px; margin: 0 4px; border-radius: 8px; text-decoration: none; color: #f0f0f0; background: linear-gradient(135deg, ${bgColor.start}, ${bgColor.end}); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; opacity: 0; animation: fadeInSlideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; animation-delay: ${animationDelay}; } .link:hover { transform: translateY(-2px) scale(1.02); box-shadow: 0 6px 20px rgba(0,0,0,0.25); filter: brightness(1.1); } .link::after { content: ''; position: absolute; top: 50%; left: 50%; width: 200%; padding-bottom: 200%; border-radius: 50%; background-image: radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%); transform: translate(-50%, -50%) scale(0); transition: transform 0.4s ease-out; } .link:hover::after { transform: translate(-50%, -50%) scale(1); } .icon { width: 28px; height: 28px; border-radius: 50%; margin-right: 12px; flex-shrink: 0; object-fit: cover; border: 2px solid rgba(255,255,255,0.3); box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .name { font-weight: 500; font-size: 14px; text-shadow: 0 1px 2px rgba(0,0,0,0.3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } `; const html = ` <style>${styles}</style> <a href="/app/topic/link/${topic.topic_id}" class="link"> <img class="icon" src="${topic.pic_url}" alt="${topic.name}"> <span class="name">${topic.name}</span> </a> `; sanitizer.setInnerHTML(this.shadowRoot, html); } catch (e) { this._logger.error('[Caliber] Failed to render custom menu item:', e); this.shadowRoot.innerHTML = `<div style="color: red; font-size: 10px; padding: 4px;">Render Error</div>`; } } } customElements.define(this.#componentItemTag, MenuItem); } } } /** * 移动端浏览 */ class ResponsiveBrowserModule extends Caliber.Module { id = 'responsiveBrowser'; name = '小黑盒移动端浏览'; description = '部分链接打开的页面没有pc适配,自动切换成移动端浏览模式,方便浏览WIKI、游戏等内容。'; match = ['/wiki', '/game', '/tools', '/activity', '/mall']; defaultConfig = { enabled: true, }; #phoneWidth = '430px'; #tabletWidth = '80vw'; #phoneHeight = '90vh'; #screenRadius = '30px'; #phoneBodyColor = '#1c1c1e'; #backgroundColor = '#f0f2f5'; #styleId = 'caliber-responsive-browser-style'; #originalTitle = ''; #elements = {}; #eventHandlers = {}; #pipWindow = null; #cssToInjectInIframe = ` *::-webkit-scrollbar { width: 8px; height: 8px; } *::-webkit-scrollbar-track { background: transparent; } *::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.25); border-radius: 10px; border: 2px solid transparent; background-clip: content-box; } *::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.4); background-clip: content-box; } html .hb-share-header, html .hb-qrc { display: none !important; } body.share, html #app>.container,html #app > .static-color-header { padding-top: 0 !important; margin-top: 0 !important; } body.sticky.share .cpt-tag-nav.fixed { top: 0 !important; } html .cpt-nav { padding: 6px 0 !important; max-height: 40px !important; } html .cpt-nav > .logo-icon { margin-bottom: 0 !important; } html .cpt-nav + .header-container { padding-top: 18px !important; } html .cpt-nav + .header-container + .sticky-box { top: 28px !important; } html #app > .fixed-nav { height:auto; } html #app > .hb-page-nav,html #app > .hb-page-nav .base-wrapper { height:40px !important; padding-bottom: 0; } html #app > .rat-gold-luck .rat-gold-luck-header-title { padding-top: 40px !important; } html #app > .rat-gold-luck .sound-switch-container, html #app .container .page-tab-container { top: 40px !important; } `; onEnable() { this._logger.log(`Module '${this.id}' activated. Taking over page rendering.`); this.#originalTitle = this._hostDocument.title || '小黑盒移动端模拟浏览'; if (this._hostWindow.matchMedia('(prefers-color-scheme: dark)').matches) { this.#backgroundColor = '#444c5e' } this._hostWindow.stop(); this._hostDocument.documentElement.innerHTML = ` <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${this.#originalTitle}</title> </head> <body></body> `; this.#createDOMElements(); this.#bindEventHandlers(); this.#injectMainStyles(); this._hostDocument.body.appendChild(this.#elements.mainWrapper); } onDisable() { this._hostWindow.location.reload(); } #jsToInjectInIframe = ` const proxiedFunctions = new WeakMap(); const NAVIGATION_RULES = [ { type: 'data', check: (vm) => vm.cardData?.tier_list_id && vm.cardData?.heybox_info?.userid && vm.cardData?.user_tier_id, getUrl: (vm) => \`/tools/rank_collection/rank_maker?tier_list_id=\${vm.cardData.tier_list_id}&heybox_id=\${vm.cardData.heybox_info.userid}&user_tier_id=\${vm.cardData.user_tier_id}\` }, { type: 'data', check: (vm) => vm.item?.link_id, getUrl: (vm) => \`/app/bbs/link/\${vm.item.link_id}\` }, { type: 'data', check: (vm) => vm.archive?.link_id && vm.steam_id, getUrl: (vm) => \`/tools/archive_square/archive_detail?link_id=\${vm.archive.link_id}&steam_id=\${vm.steam_id}\` }, { type: 'method', methodName: 'jumpHome', getUrl: (args) => args[0] === 'square' ? "/tools/archive_square/home" : null }, { type: 'method', methodName: 'jumpSimulator', getUrl: (args) => console.log(args) }, ]; function performNavigation(url, event) { if (!url) return false; event.preventDefault(); event.stopPropagation(); setTimeout(() => { window.location.href = url; }, 0); return true; } document.addEventListener('click', function (event) { let target = event.target; let dataMatchFound = null; const vmsToProcess = []; while(target) { if (target.__vue__) { vmsToProcess.push(target.__vue__); } target = target.parentElement; } if (vmsToProcess.length === 0) return; for (const vm of vmsToProcess) { if (!dataMatchFound) { for (const rule of NAVIGATION_RULES) { if (rule.type === 'data' && rule.check(vm)) { dataMatchFound = { vm, rule }; break; } } } for (const rule of NAVIGATION_RULES) { if ((rule.type === 'method' || rule.type === 'closure') && typeof vm[rule.methodName] === 'function') { const originalFunc = vm[rule.methodName]; if (proxiedFunctions.has(originalFunc)) { continue; } let newFunc; if (rule.type === 'method') { newFunc = function(...args) { const url = rule.getUrl(args); if (performNavigation(url, event)) return; return originalFunc.apply(this, args); }; } else { // todo } vm[rule.methodName] = newFunc; proxiedFunctions.set(originalFunc, newFunc); } } } if (dataMatchFound) { setTimeout(() => { const { vm, rule } = dataMatchFound; const url = rule.getUrl(vm); performNavigation(url, event); }, 0); } }, true); `; #injectJsInIframe(iframeDocument) { if (!this.#jsToInjectInIframe) return; const scriptElement = iframeDocument.createElement('script'); scriptElement.textContent = this.#jsToInjectInIframe; iframeDocument.head.appendChild(scriptElement); } #createDOMElements() { this.#elements.mainWrapper = this._hostDocument.createElement('div'); this.#elements.mainWrapper.id = 'main-wrapper'; this.#elements.controlsContainer = this._hostDocument.createElement('div'); this.#elements.controlsContainer.id = 'controls-container'; this.#elements.toggleSwitch = this._hostDocument.createElement('div'); this.#elements.toggleSwitch.id = 'device-toggle-switch'; this.#elements.toggleSwitch.title = '切换手机/平板视图'; this.#elements.toggleSwitch.innerHTML = ` <span class="label label-tablet">平板</span> <span class="label label-phone">手机</span> <div class="switch-thumb"></div> `; this.#elements.pipButton = this._hostDocument.createElement('div'); this.#elements.pipButton.id = 'pip-button'; this.#elements.pipButton.title = '窗口模式'; this.#elements.pipButton.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6m5 0h5v5m-6-4 5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`; this.#elements.phoneContainer = this._hostDocument.createElement('div'); this.#elements.phoneContainer.id = 'mobile-view-container'; this.#elements.phoneHeader = this._hostDocument.createElement('div'); this.#elements.phoneHeader.id = 'phone-header'; this.#elements.backButton = this._hostDocument.createElement('div'); this.#elements.backButton.id = 'back-button'; this.#elements.backButton.title = '返回'; this.#elements.backButton.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`; this.#elements.phoneTitle = this._hostDocument.createElement('div'); this.#elements.phoneTitle.id = 'phone-title'; this.#elements.phoneTitle.textContent = this.#originalTitle; this.#elements.iframe = this._hostDocument.createElement('iframe'); this.#elements.iframe.src = this._hostWindow.location.href; this.#elements.iframe.id = 'mobile-view-iframe'; this.#elements.phoneHeader.appendChild(this.#elements.backButton); this.#elements.phoneHeader.appendChild(this.#elements.phoneTitle); this.#elements.phoneHeader.appendChild(this._hostDocument.createElement('div')); this.#elements.phoneContainer.appendChild(this.#elements.phoneHeader); this.#elements.phoneContainer.appendChild(this.#elements.iframe); this.#elements.controlsContainer.appendChild(this.#elements.toggleSwitch); this.#elements.controlsContainer.appendChild(this.#elements.pipButton); this.#elements.mainWrapper.appendChild(this.#elements.controlsContainer); this.#elements.mainWrapper.appendChild(this.#elements.phoneContainer); } #bindEventHandlers() { this.#eventHandlers.onToggleClick = () => { this.#elements.phoneContainer.classList.toggle('is-tablet-mode'); this.#elements.toggleSwitch.classList.toggle('is-tablet-mode'); }; this.#eventHandlers.onBackClick = () => { this._hostWindow.history.back(); }; this.#eventHandlers.onPipClick = () => { this.#openPipWindow(); }; this.#eventHandlers.onIframeLoad = () => { try { const iDoc = this.#elements.iframe.contentDocument || this.#elements.iframe.contentWindow.document; const styleElement = iDoc.createElement('style'); styleElement.textContent = this.#cssToInjectInIframe; iDoc.head.appendChild(styleElement); this.#injectJsInIframe(iDoc); if (iDoc.title) { this.#elements.phoneTitle.textContent = iDoc.title; } } catch (e) { this._logger.error('Failed to inject CSS into iframe (likely cross-origin sandbox restrictions):', e); } }; this.#elements.toggleSwitch.addEventListener('click', this.#eventHandlers.onToggleClick); this.#elements.backButton.addEventListener('click', this.#eventHandlers.onBackClick); this.#elements.iframe.addEventListener('load', this.#eventHandlers.onIframeLoad); this.#elements.pipButton.addEventListener('click', this.#eventHandlers.onPipClick); } async #openPipWindow() { if (this.#pipWindow) { this.#pipWindow.focus(); return; } let currentUrl; try { currentUrl = this.#elements.iframe.contentWindow.location.href; } catch (error) { currentUrl = this._hostWindow.location.href; } try { const pipWindow = await this._hostWindow.documentPictureInPicture.requestWindow({ width: 430, height: 850, }); this.#pipWindow = pipWindow; const iframe = pipWindow.document.createElement('iframe'); iframe.src = currentUrl; iframe.style.cssText = 'width: 100%; height: 100%; border: none; flex-grow: 1;'; pipWindow.document.body.append(iframe); pipWindow.document.documentElement.style.height = '100%'; pipWindow.document.body.style.height = '100%'; pipWindow.document.body.style.margin = '0'; pipWindow.document.body.style.overflow = 'hidden'; pipWindow.addEventListener('pagehide', () => { this._logger.log('Picture-in-Picture window closed.'); this.#pipWindow = null; }); } catch (error) { this._logger.error('Failed to open Picture-in-Picture window:', error); } } #injectMainStyles() { const mainCss = ` html { overflow: hidden !important; } body { background-color: ${this.#backgroundColor} !important; margin: 0 !important; padding: 0 !important; display: flex !important; justify-content: center !important; align-items: center !important; height: 100vh !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } #main-wrapper { display: flex; flex-direction: column; align-items: center; gap: 20px; } #controls-container { display: flex; align-items: center; gap: 15px; } #device-toggle-switch { position: relative; width: 90px; height: 34px; border-radius: 17px; background-color: ${this.#backgroundColor}; box-shadow: inset 2px 2px 4px #d0d0d0, inset -2px -2px 4px #ffffff; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; padding: 0 14px; box-sizing: border-box; } #pip-button { width: 34px; height: 34px; border-radius: 50%; background-color: ${this.#backgroundColor}; box-shadow: 2px 2px 4px #c8c8c8, -2px -2px 4px #ffffff; cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: center; transition: box-shadow 0.2s ease-in-out; } #pip-button:hover { box-shadow: 1px 1px 2px #c8c8c8, -1px -1px 2px #ffffff; } #pip-button:active { box-shadow: inset 1px 1px 2px #c8c8c8, inset -1px -1px 2px #ffffff; } #pip-button svg { width: 18px; height: 18px; color: #555555; } .label { font-size: 12px; font-weight: 500; color: #555555; } .switch-thumb { position: absolute; top: 3px; left: 3px; width: 44px; height: 28px; border-radius: 14px; background: linear-gradient(145deg, #fdfdfd, #e6e6e6); box-shadow: 2px 2px 4px #c8c8c8, -2px -2px 4px #ffffff; transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1); } #device-toggle-switch.is-tablet-mode .switch-thumb { transform: translateX(40px); } #mobile-view-container { position: relative; width: ${this.#phoneWidth}; height: ${this.#phoneHeight}; max-height: 960px; background: ${this.#phoneBodyColor}; border: 3px solid #4a4a4a; border-radius: 40px; box-shadow: 0 15px 40px -10px rgba(0,0,0,0.6), inset 0 2px 3px rgba(255,255,255,0.1); padding: 12px; box-sizing: border-box; display: flex; flex-direction: column; gap: 10px; transition: width 0.4s ease-in-out; } #phone-header { display: flex; justify-content: space-between; align-items: center; height: 30px; flex-shrink: 0; color: #e0e0e0; } #back-button { width: 30px; height: 30px; cursor: pointer; display: flex; align-items: center; justify-content: center; } #back-button svg { width: 22px; height: 22px; } #phone-title { flex-grow: 1; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: 15px; } #phone-header > div:last-child { width: 30px; } #mobile-view-container.is-tablet-mode { width: ${this.#tabletWidth}; } #mobile-view-iframe { width: 100%; height: 100%; flex-grow: 1; border: none; border-radius: ${this.#screenRadius}; background: #fff; } `; this._services.style.add(mainCss, this.#styleId); } } /** * Google Analytics 拦截模块 */ class AnalyticsBlockerModule extends Caliber.Module { id = 'analyticsBlocker'; name = 'Google Analytics追踪拦截'; description = '如果不想把你的访问数据发送给Google Analytics,就开启它吧。'; defaultConfig = { enabled: false, }; onEnable() { const injectionCode = `window._gaUserPrefs = { ioo: () => true };`; this._sanitizer.injectScript(this._hostDocument, injectionCode); } onDisable() { const cleanupCode = `try { delete window._gaUserPrefs; } catch (e) { window._gaUserPrefs = undefined; }`; this._sanitizer.injectScript(this._hostDocument, cleanupCode); } } /** * 窗口模式模块 */ class ZenModeModule extends Caliber.Module { id = 'zenMode'; name = '窗口模式 (Beta)'; description = '在右上角个人菜单中添加“窗口模式”按钮。该功能是实验性功能,可能会有问题。'; defaultConfig = { enabled: false, }; uiGuard = { target: 'div.view-header__user-box div.user-box__pull-list', component: '#caliber-zen-mode-btn' }; #pipWindow = null; onRender(targetElement) { const button = this._hostDocument.createElement('button'); button.id = this.uiGuard.component.substring(1); button.textContent = '窗口模式 (Beta)'; button.className = 'user-box__btn'; // 是否支持此实验性功能 if ('documentPictureInPicture' in this._hostWindow) { button.addEventListener('click', this.#handleClick); } targetElement.prepend(button); } onCleanup() { const button = this._hostDocument.querySelector(this.uiGuard.component); if (button) { button.remove(); } } #handleClick = async () => { if (this.#pipWindow) { try { this.#pipWindow.focus(); return; } catch (e) { } } const currentUrl = this._hostWindow.location.href; this._logger.log('Requesting Picture-in-Picture window...'); try { const pipWindow = await this._hostWindow.documentPictureInPicture.requestWindow({ width: 430, height: 850, }); this.#pipWindow = pipWindow; const ZEN_MODE_IFRAME_FLAG = 'CALIBER_ZEN_MODE_IFRAME'; const iframe = pipWindow.document.createElement('iframe'); iframe.src = currentUrl; iframe.name = ZEN_MODE_IFRAME_FLAG; iframe.style.cssText = 'width: 100%; height: 100%; border: none; flex-grow: 1;'; pipWindow.document.body.append(iframe); pipWindow.document.documentElement.style.height = '100%'; pipWindow.document.body.style.height = '100%'; pipWindow.document.body.style.margin = '0'; pipWindow.document.body.style.overflow = 'hidden'; pipWindow.addEventListener('pagehide', () => { this._logger.log('Picture-in-Picture window closed.'); this.#pipWindow = null; }); } catch (error) { this._logger.error('Failed to open Picture-in-Picture window:', error); } }; } /** * 页面个性化模块 */ class ThemeEnhancerModule extends Caliber.Module { id = 'themeEnhancer'; name = '页面个性化(Beta)'; description = '为网站提供简单的个性化样式。未做适配,会有一些样式兼容性问题。应避免和夜间模式同时使用。'; defaultConfig = { enabled: false, enableThemeOverrides: { label: '启用主题色彩与效果', type: 'boolean', value: true, description: '控制下方所有颜色、透明度、磨砂玻璃效果的开关。' }, primaryColor: { label: '主题色', type: 'color', value: '#FFF', description: '选择一个您喜欢的主题色,它将影响链接、按钮等元素的颜色。浅色效果更好。', indentLevel: 1, }, backgroundOpacity: { label: '背景透明度', type: 'number', value: 85, inputProps: { min: 0, max: 100, step: 1, }, description: '设置部分背景元素的透明度 (0-100)。值越小越透明。', indentLevel: 1, }, frostedGlassBlur: { label: '磨砂玻璃模糊度', type: 'number', value: 6, description: '设置磨砂玻璃效果的模糊半径。设置为 0 则禁用此效果。开启此效果可能会导致页面闪烁以及部分布局异常。', inputProps: { min: 0, max: 50, step: 1 }, indentLevel: 1, }, backgroundImageUrl: { label: '背景图片地址', type: 'string', value: '', description: '输入一张在线图片的URL地址,为页面设置自定义背景,留空则不使用。建议把图片上传第三方图床使用。', divider: 'top', inputProps: { placeholder: 'https://example.com/image.png' } } }; #styleId = 'caliber-theme-enhancer-style'; #themeClass = 'theme-enhanced'; onEnable() { this._logger.log(`Module '${this.id}' enabled. Applying initial styles.`); this.#applyStyles(); } onDisable() { this._logger.log(`Module '${this.id}' disabled. Cleaning up styles.`); this.#cleanupStyles(); } onConfigChange(key, newValue, oldValue) { this._logger.log(`Theme config '${key}' changed. Re-applying styles.`); this.#applyStyles(); } #applyStyles() { const dynamicCss = this.#generateDynamicCss(); if (dynamicCss) { this._services.style.add(dynamicCss, this.#styleId); this._hostDocument.documentElement.classList.add(this.#themeClass); } else { this.#cleanupStyles(); } } #cleanupStyles() { this._services.style.remove(this.#styleId); this._hostDocument.documentElement.classList.remove(this.#themeClass); } #generateDynamicCss() { const { enableThemeOverrides, primaryColor, backgroundOpacity, frostedGlassBlur, backgroundImageUrl } = this._config; let finalCssParts = []; if (enableThemeOverrides) { const lighterHighlightColor = this.#adjustHexColorBrightness(primaryColor, 15, backgroundOpacity); const darkerHighlightColor = this.#adjustHexColorBrightness(primaryColor, -5, backgroundOpacity); const darkerHighlightColor2 = this.#adjustHexColorBrightness(primaryColor, -15, backgroundOpacity); const primaryColorRgba = this.#hexToRgbaValues(primaryColor, backgroundOpacity); const primaryFontColor = this.#adjustHexColorBrightness(primaryColor, -75, backgroundOpacity); const cssVariables = ` --color-primary-white--value: ${primaryColorRgba}; --color-background-1: ${darkerHighlightColor2}; --color-border-3: ${lighterHighlightColor}; --color-background-3: ${lighterHighlightColor}; --color-background-2: rgba(${primaryColorRgba}); --color-font-4: ${primaryFontColor}; --color-primary-black :${primaryFontColor}; .game-detail-section-data .game-info, .game-detail-section-data .game-data .row-3 .data-list .data-item, .hb-cpt__loading.circle, .game-detail-page-topic .page-topic-header, .game-detail-section-footer, .game-detail-section-data .game-data, .hb-cpt-login-mask .hb-cpt-login .left-box, .phone-login-wrapper, .hb-cpt__link-game-card{ background-color: var(--color-background-3); } .game-detail-comment-item:after { background-color: ${darkerHighlightColor}; } .game-detail-comment-item .tools .item, .hb-game-comment .game-comment__content .game-comment__content-tools .item, .game-detail-section-data .game-data .btn-see-all, .game-detail-section-data .game-info .hardware-performance { background-color: var(--color-background-1); } .game-detail-section-comment, .game-detail-section-score { border-top-color: ${darkerHighlightColor}; } .game-detail-comment-item .description .btn-all{ background-color: var(--color-background-1); color: var(--color-primary-black); &::after{ background: linear-gradient(270deg, var(--color-background-1), transparent); } } .hb-cpt__slide-tab { background-color: transparent; } .game-detail-page-topic .page-topic-header { postion: relative; &::before,&::after { content: ''; position: absolute; top: 0; background-color: var(--color-background-3); width: 16px; height: 100%; } &::before{ left: -16px; } &::after{ right: -16px; } } .hb-cpt__pagination .hb-cpt__pagination--right { background: linear-gradient(270deg, rgba(${primaryColorRgba}) 0, rgba(${primaryColorRgba}) 50%, #fff0); border-radius: 0 12px 12px 0; } .hb-cpt__pagination .hb-cpt__pagination--left { background: linear-gradient(90deg, rgba(${primaryColorRgba}) 0, rgba(${primaryColorRgba}) 50%, #fff0); border-radius: 12px 0 0 12px; } `; finalCssParts.push(cssVariables); if (frostedGlassBlur > 0) { const frostedGlassCss = ` .hb-view-header, .search__pull-list, .view-header__right .view-header__user-box .user-box__pull-list .user-box__btn, .hb-cpt__scroll-list, .link-reply, .hb-cpt__recent-hot-topic, .bbs-link__related-recommend, .hb-view-catalog, .hb-cpt-page-header { backdrop-filter: blur(${frostedGlassBlur}px) saturate(2.5); -webkit-backdrop-filter: blur(${frostedGlassBlur}px) saturate(2.5); } .hb-cpt__scroll-list .scroll-list__button-group { transform: translateZ(0) translateX(calc(50vw - 200px)) translateY(100%); } @media (max-width: 640px) { .hb-cpt__scroll-list .scroll-list__button-group { display: none !important; } } `; finalCssParts.push(frostedGlassCss); } } if (backgroundImageUrl && typeof backgroundImageUrl === 'string' && backgroundImageUrl.trim() !== '') { const sanitizedUrl = backgroundImageUrl.trim(); const backgroundCss = ` & body::before { content: ''; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: -1; background-image: url("${sanitizedUrl}"); background-blend-mode: screen; background-repeat: no-repeat; background-position: center; background-size: cover; background-attachment: scroll; } `; finalCssParts.push(backgroundCss); } if (finalCssParts.length === 0) { return ''; } const defaultCss = ` .hb-bbs-home .bbs-home__topic-list-wrapper.hb-bbs-home__splitline::after{ background-color: transparent; } .hb-layout__fake-frame-left--top svg, .hb-layout__fake-frame-left--bottom svg { display: none !important; } .game-detail-comment-item:after { left: 0; width: 100%; } `; finalCssParts.push(defaultCss); return ` html.${this.#themeClass} { ${finalCssParts.join('\n')} } `; } /** * 将HEX颜色转换为RGBA格式字符串 * @param {string} hexColor - HEX颜色 * @param {number} opacity - 透明度百分比 (0-100) * @param {number} opacityOffset - 透明度偏移量 * @returns {string} "R, G, B, A" 格式字符串 */ #hexToRgbaValues(hexColor, opacity, opacityOffset = 0) { const finalOpacity = Math.max(0, Math.min(100, opacity + opacityOffset)); const alpha = (finalOpacity / 100).toFixed(2); let hex = hexColor.replace(/^#/, ''); if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return `${r}, ${g}, ${b}, ${alpha}`; } /** * 调整HEX颜色的亮度并可选择添加透明度 * @param {string} hexColor - 原始HEX颜色,支持3位或6位格式 * @param {number} percent - 调整百分比,正数变亮,负数变暗 * @param {number} [alpha=100] - 透明度值,0-100之间,100为完全不透明 * @returns {string} 调整后的HEX颜色,含透明度(如果alpha<1) */ #adjustHexColorBrightness(hexColor, percent, alpha = 100) { // 验证输入 if (!hexColor || typeof hexColor !== 'string') { throw new Error('hexColor必须是一个非空字符串'); } // 规范化HEX颜色(去除#,扩展3位缩写) let hex = hexColor.replace(/^#/, ''); if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } if (!/^[0-9A-F]{6}$/i.test(hex)) { throw new Error('无效的HEX颜色格式'); } // 解析RGB值 const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); // 转换为HSL const [h, s, l] = this.#rgbToHsl(r, g, b); // 调整亮度 const adjustedL = Math.max(0, Math.min(1, l + (l * percent / 100))); // 转换回RGB const [r2, g2, b2] = this.#hslToRgb(h, s, adjustedL); // 转换为HEX const toHex = value => { const hex = Math.round(value).toString(16); return hex.length === 1 ? '0' + hex : hex; }; const hexResult = `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`; if (alpha < 100) { const alphaHex = Math.round((alpha / 100) * 255).toString(16).padStart(2, '0'); return hexResult + alphaHex; } return hexResult; } /** * 将RGB颜色转换为HSL */ #rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; // 无色相 } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [h, s, l]; } /** * 将HSL颜色转换为RGB */ #hslToRgb(h, s, l) { let r, g, b; if (s === 0) { r = g = b = l; // 灰色 } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } return [r * 255, g * 255, b * 255]; } } // #endregion // #region ================================ 应用启动区 (Application Bootstrap) ========================== (function () { 'use strict'; const ZEN_MODE_IFRAME_FLAG = 'CALIBER_ZEN_MODE_IFRAME'; if (window.self !== window.top && window.name !== ZEN_MODE_IFRAME_FLAG) { return; } const tampermonkeyStorageAdapter = (storageKey) => ({ get: () => GM.getValue(storageKey, {}), set: (value) => GM.setValue(storageKey, value), }); const tampermonkeyCommandAdapter = { register: (name, callback) => GM.registerMenuCommand(name, callback), }; const tampermonkeyStyleAdapter = { _addedStyles: new Map(), async add(cssString, id) { try { if (this._addedStyles.has(id)) { this.remove(id); } const styleElement = await GM.addStyle(cssString); this._addedStyles.set(id, styleElement); } catch (e) { console.error(`[小黑盒Pro StyleAdapter] GM.addStyle failed for id '${id}':`, e); } }, remove(id) { if (this._addedStyles.has(id)) { const styleElement = this._addedStyles.get(id); if (styleElement && typeof styleElement.remove === 'function') { styleElement.remove(); } else { console.warn(`[小黑盒Pro StyleAdapter] Could not remove style '${id}'. The returned object may not be a standard element.`, styleElement); } this._addedStyles.delete(id); } } }; const appOptions = { appName: '小黑盒Pro', isDebug: false, settingsPanelEnabled: true, modules: [ ThemeSwitcherModule, BackButtonFixModule, NewTabPageModule, QuickJumpToCommentsModule, SteamDirectLinkModule, FeedsEnhancementModule, TopicEnhancementModule, CustomMenuModule, ResponsiveBrowserModule, ZenModeModule, ThemeEnhancerModule, AnalyticsBlockerModule, ], services: { storage: tampermonkeyStorageAdapter('HEYBOX_ENHANCER_CONFIG'), command: tampermonkeyCommandAdapter, style: tampermonkeyStyleAdapter, }, framework: { domProcessorBatchSize: 10, } }; // --- 启动应用 --- Caliber.createApp(appOptions).catch(error => { console.error(`[${appOptions.appName}]: A fatal error occurred during bootstrap.`, error); }); })();