// ==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);
});
})();