2025年3月4日 - scarletborder
新手向,通过学习一些最佳实践得出编写liteloaderQQNT插件的经验心得
在这篇教程中,笔者会介绍开发插件的一些必备知识,最终带领读者开发一个插件,这个插件会在设置栏目中增加选项以控制是否启用后续功能,如是,为QQNT的窗口增加一个元素,点击这个元素后会彻底关闭QQNT进程.
本篇文章已经发在github liteloaderQQNT-turtorial/README.md at main · scarletborder/liteloaderQQNT-turtorial
参考
https://github.com/xiyuesaves/LiteLoaderQQNT-lite_tools
https://github.com/WJZ-P/LiteLoaderQQNT-Encrypt-Chat
感谢他们为liteloaderQQNT社区做的贡献,因为他们的贡献笔者也得以总结此文
electron 基础
如果你已经掌握,请快进到准备工作,
或者你可以通过以下两种方式了解electron
准备工作
在开始编写插件前,首先要规划一下要实现的功能有哪些,在配置项中要添加哪些配置项,一方面这是为了计划在主进程中需要暴露(handle)哪些方法,另一方面可以让你的插件项目结构更加整洁.
例如我们将要开发一个插件,他要
为QQNT的主窗口的更多选项栏增加一个关闭NTQQ应用的按钮.
允许用户选择点击主窗口的'X'后直接关闭NTQQ应用. (在之后我们会知道这是不能轻易实现的,因此笔者折中选择了关闭所有可见的窗口后判断直接关闭NTQQ应用)
那么,我们要在settings中增加控制,控制点击主窗口的'X'后的逻辑.而更多选项栏中关闭NTQQ按钮则是一直添加的.
编写
启动前
因为上文中提到的渲染进程的能力受限,为了让我们承担业务逻辑的主力--渲染进程的能力更大,我们需要在preload中将需要的方法尽可能暴露给渲染进程.
而在此之前我们要在主进程中编写一些消息处理件,再让preload.js暴露他们.
主进程中
需要在主进程的全局作用域中定义所需的ipcMain handler,设置ipcMain.handle和ipcMain.on.这个过程就像写服务端软件时的定义路由.你甚至可以仅仅定义一个ipcMain.handle和一个ipcMain.on,在message中设置type字段做dispatch逻辑.
本例中将增加一个quit handler,它不需要返回任何消息.增加一个读取配置和写入配置的handler,前者需要返回信息,后者目前认为是不需要的.
xxxxxxxxxxipcMain.on('scb.forceQuit.quit', (event) => { app.quit();});
ipcMain.on('scb.forceQuit.saveConfig', (event, config) => { myConfig.saveConfig(config);});
ipcMain.handle('scb.forceQuit.getConfig', (event) => { return Promise.resolve(myConfig.getConfig());});同时还可以在全局作用域定义一些init函数,让他们在插件加载后立即执行,例如读取config,一些默认状态设置.
xxxxxxxxxxlet windowCount = 0;let everWindowCount = 0;接着如果你有要在QQNT任何窗口(如登陆窗口,主chat窗口,settings窗口)创建后需要执行的逻辑,在liteloader规定的onBrowserWindowCreated()函数中编写你的逻辑.
本例中对所有窗口创建的逻辑做了两件事情,
向依赖
lib_moremenu发送通知,要求注册事件,该依赖会以插件名字做key维护set,因此无论执行多少次注册函数,影响只会在第一次产生.增加监听"关闭窗口事件"逻辑.在这里笔者的逻辑是,如果关闭目前窗口后,剩余的窗口数量小于1(没有了)且已经打开过4个窗口,则根据是配置决定强制退出应用.
为什么不能指定是chat窗口呢? 因为NTQQ给所有的窗口设置的title都是
根据笔者调试统计,windows 26466版本下
运行QQ后,弹出的"头像+登录按钮"的窗口是第2个窗口(之前已经隐形的打开了1个窗口并关闭)
主聊天窗口是第4个窗口,如果此时再打开更多菜单->设置,那这是第5个窗口
xxxxxxxxxx// src/main/index.tsimport { ipcMain, BrowserWindow, app } from 'electron';import { myConfig } from './config';
export const onBrowserWindowCreated = (window: BrowserWindow) => { windowCount++; everWindowCount++; // 注册更多菜单 const MoreMenuRegister = global.MoreMenuRegister; MoreMenuRegister.registerPlugin('scb_forceQuit');
window.on('close', () => { windowCount--; if (windowCount < 1 && everWindowCount >= 4) { // 幽默qq所有窗口title都叫'QQ' if (myConfig.getConfig().forceQuit) { app.quit(); } } }); return;};
preload.js
Electron 默认启用了 contextIsolation(上下文隔离,从 Electron 12 开始推荐),这将渲染进程的 JavaScript 与 Chromium 的 Web 环境分开。
preload.js 运行在一个独立的上下文,可以访问 Node.js API 和 Electron 的模块(如 ipcRenderer),并通过 contextBridge 将特定的功能暴露给渲染进程,避免直接暴露敏感 API.
在这里我们要将方法通过contextBridge暴露给渲染进程,使用contextBridge.exposeInMainWorld函数
一个实践例子如下,他们将承担你在主要业务逻辑的部分OS(如fs和app)操作能力.
xxxxxxxxxximport { contextBridge, ipcRenderer } from 'electron';import { IConfig } from '../model';
contextBridge.exposeInMainWorld('forceQuit', { // quit quit: () => ipcRenderer.send('scb.forceQuit.quit'), // config getConfig: () => ipcRenderer.invoke('scb.forceQuit.getConfig'), saveConfig: (config: IConfig) => ipcRenderer.send('scb.forceQuit.saveConfig', config), //debug countWindow: () => ipcRenderer.invoke('debug.countwinodw'), // deprecared, load menu html , // use getMenuHTML in renderer process directly // getMenuHTML: () => ipcRenderer.invoke('LiteLoader.forceQuit.getMenuHTML'),});
渲染进程
实践上采用监听事件驱动的callback中或者主作用域的函数中执行重要逻辑.
如果你不确定这里的逻辑何时可以执行,document中何时出现需要的元素,你可以设置各种定时器来定时处理逻辑,或者使用MutationObserver来监听DOM树的变化.
首先,在全局作用域中,前文说到我们的插件依赖一个lib库,我们在渲染进程需要向他添加我们的菜单配置.由于不知道liteloader加载插件顺序,因此我们定时判断window.MoreMenuManager何时被定义.并执行添加逻辑
xxxxxxxxxxconst forceAPI = window.forceQuit; // 本插件preload暴露给window
const timer = setInterval(() => { const MoreMenuManager = window.MoreMenuManager; if (MoreMenuManager && MoreMenuManager != undefined) { MoreMenuManager.AddItem(exitSvgIcon, 'Exit QQ', () => { forceAPI.quit(); }, 'scb_forceQuit');
MoreMenuManager.Done(); clearInterval(timer); }
}, 500);在liteloader规定的onSettingWindowCreated()中你还可以为自己的插件创建自己的settings目录.
这部分内容请见创建settings
更多操作
store
configure
可以通过导出的类静态变量来存储应用的配置或者用于状态管理,其他插件可以通过访问这个类的实例或其静态方法来获取和更新配置项.
这里我们讲解使用类的全局唯一实例来管理应用配置.
首先定义目录项,方便在各处使用类型注释而不会产生循环引用
xxxxxxxxxx// src/model.tsexport interface IConfig { forceQuit: boolean; // 点击X后直接退出吗,否则为最小化到托盘}xxxxxxxxxx// src/main/config.jsimport path from 'path';import fs from 'fs';import { IConfig } from '../model';
const pluginName = 'lite_loader_qqnt_force_exit';
class Config { private _config: IConfig = { forceQuit: false }; public pluginPath: string = ''; public configPath: string = '';
constructor() { this.pluginPath = path.join(LiteLoader.plugins.lite_loader_qqnt_force_exit.path.plugin); this.configPath = path.join(this.pluginPath, 'config.json'); if (!fs.existsSync(this.configPath)) { fs.writeFileSync(this.configPath, JSON.stringify(this._config, null, 4), 'utf-8'); } else { Object.assign(this._config, JSON.parse(fs.readFileSync(this.configPath, 'utf-8').toString())); } }
public getConfig(): IConfig { return this._config; }
public async saveConfig(config: IConfig): Promise<void> { fs.writeFileSync(this.configPath, JSON.stringify(config, null, 4), 'utf-8'); Object.assign(this._config, config); }}
export const myConfig = new Config();在main进程中暴露这些api
xxxxxxxxxxipcMain.on('scb.forceQuit.saveConfig', (event, config) => { myConfig.saveConfig(config);});
ipcMain.handle('scb.forceQuit.getConfig', (event) => { return Promise.resolve(myConfig.getConfig());});在preload.js中暴露给window
xxxxxxxxxxcontextBridge.exposeInMainWorld('forceQuit', { // ... // config getConfig: () => ipcRenderer.invoke('scb.forceQuit.getConfig'), saveConfig: (config: IConfig) => ipcRenderer.send('scb.forceQuit.saveConfig', config),});渲染进程中任意逻辑代码调用,这里的例子是下文中settings的选项切换逻辑中的一部分.
xxxxxxxxxx const deboucedSaveConfig = debounce((oldIsForceQuit: boolean) => { cfg.forceQuit = !oldIsForceQuit; forceAPI.saveConfig(cfg); }, 300);
deboucedSaveConfig(!(isActive === null));持久化
通过fs进行持久化配置修改.例如上文config类中,
插件地址
path.join(LiteLoader.plugins.lite_loader_qqnt_force_exit.path.plugin)插件数据文件地址,例如这里的配置文件
path.join(this.pluginPath, 'config.json')
第一次使用没有数据文件会按照config类构造函数指定的那样初始化,读取原有数据文件并进行后续逻辑.
xxxxxxxxxxif (!fs.existsSync(this.configPath)) { fs.writeFileSync(this.configPath, JSON.stringify(this._config, null, 4), 'utf-8');} else { Object.assign(this._config, JSON.parse(fs.readFileSync(this.configPath, 'utf-8').toString()));}
settings
这里外部的setTimeout是为了等待笔者打开console,以及规避一些难以查明的问题
xxxxxxxxxxexport const onSettingWindowCreated = async (view: HTMLElement) => { setTimeout(async () => { // 读取配置 const cfg: IConfig = await forceAPI.getConfig();
// 添加settings 页面 const parser = new DOMParser(); const settingHTML = parser.parseFromString(pluginHtml(cfg), 'text/html').querySelector('plugin-menu'); if (!settingHTML) { console.log('Setting HTML not found'); return; } view.appendChild(settingHTML); console.log('Setting HTML added'); const settingsTimer = setInterval(() => { // 监听相关选项,只有当用户首次点击选项卡的时候,才会有相关元素出现 const switchForceQuit = document.getElementById('settings.isForceQuit'); if (!switchForceQuit) { return; } clearInterval(settingsTimer);
console.log(switchForceQuit); if (switchForceQuit) { console.log('start listen'); switchForceQuit.addEventListener('click', (e) => { console.log(e); // 原先的属性 const isActive = (e.target as HTMLInputElement).getAttribute('is-active'); // 设置新的属性 (e.target as HTMLInputElement).toggleAttribute('is-active', isActive === null); // 保存配置消抖 const deboucedSaveConfig = debounce((oldIsForceQuit: boolean) => { cfg.forceQuit = !oldIsForceQuit; forceAPI.saveConfig(cfg); }, 300);
deboucedSaveConfig(!(isActive === null)); }); } }, 200); }, 500);};pluginHtml示例,
xxxxxxxxxxconst pluginHtml = (cfg: IConfig) => `<meta charset="UTF-8"><plugin-menu> <setting-section data-title="关闭选项">
<setting-panel> <setting-list data-direction="column"> <setting-item data-direction="row"> <setting-text>启用后,关闭主窗口将直接关闭QQ进程</setting-text> <setting-switch ${cfg.forceQuit ? 'is-active' : ''} id="settings.isForceQuit"></setting-switch> </setting-item> </setting-list>
</setting-panel> </setting-section></plugin-menu>`;
现在点击切换按钮可以在视觉上看到切换动画,并且config示例以及config文件会在消抖时间后产生变化.

Important
只有在settings页面左边栏选中你的插件后,追加的内容才会出现在document中,此时才能被选择器选中.因此这里配置的定时器的执行逻辑中,最先检测document是否有目标id的元素,如果没有直接终止此次循环.
增强UI
可以参考笔者增强左下角更多菜单的过程scarletborder/LLQQNT-lib-moremenu: extension which enables user to terminate QQ process in QQ Main window
简而言之,使用MutationObserver监听DOM树变化.通过选择器.q-context-menu.more-menu.q-context-menu__mixed-type找到更多菜单.判断是否已经修改过,如无开始下列注入主要注入逻辑.
对每个插件注册过的item,通过模板创建一个more_menu_item风格的Element,并为click事件添加用户需求的callback(以及关闭菜单),之后将他添加到menu中.
例如这里增加的Exit QQ正是本教程开发插件中增加的按钮.

关于右键菜单ContextMenu,参考liteloaderQQNT-turtorial/add_context_menu.md at main · scarletborder/liteloaderQQNT-turtorial
评论区 (1)
这里再推荐一篇其他开发者的教程,写的也是比较完整.
https://github.com/nyaruhodoo/LiteLoader-BeginnerTutorial