QQ 截图启动器
看雪论坛 0xEEEE 在逆向调用QQ截图NT与WeChatOCR这篇文章中发布了调用新版 QQ 截图功能的软件 QQScreenShotNT-Plus,虽然已经设置了 QQScreenshot.exe 的路径,但是每次启动这个软件都要提示是否重新自动获取,在设置中去掉“启动提示”依然无效。在那篇文章中作者说 QQScreenShotNT-Plus 是在 QQImpl 基础上实现的,但是作者没有发布对应的源代码,因此我准备基于 Windows Forms 技术复刻相关的功能。因为是第一次使用 .NET 相关技术开发 Windows 系统的应用软件,所以会详细地记录整个开发过程。源代码在 QQScreenShotLauncher。
创建解决方案
这部分内容参考项目和解决方案简介。启动 Visual Studio 2022,在 Visual Studio 2022 对话框选择 Create a new project

在 Create a new project 对话框的在搜索框输入 solution,选择语言、平台、项目类型,然后选择 Blank Solution

在 Configure your new project 对话框输入解决方案名称 QQScreenShotLauncher,保存位置 D:\projects\dotnet\

创建 Git 仓库
这部分内容参考从 Visual Studio 创建 Git 存储库。在 Git 变更选择 Create Git Repository

在 Create a Git repository 对话框选择 Local only,Local path、.gitignore template、License template 三项使用默认值,勾选 Add a README.md 复选框

创建项目
这部分内容参考项目和解决方案简介。在解决方案资源管理器中,右键单击 QQScreenShotLauncher 解决方案,依次选择 Add 和 New Project

在搜索框输入 Windows Forms App,选择语言、平台、项目类型,然后选择 Windows Forms App

在 Configure your new project 对话框输入项目名称 NTLauncher,保存位置保持默认值

在 Additional information 对话框选择框架版本 .NET 8.0 (Long Term Support)

添加图标资源
这部分参考管理应用程序资源。在解决方案资源管理器中,右键单击 NTLauncher 项目,选择 Properties

选中 Resources > General,点击 Create or open assembly resources 链接

在 Resource Explorer 点击加号按钮

在 Add a new resource 对话框,Type 下拉框选择 Icon,其他保持默认,然后点击 Add existing file 按钮

在文件选择对话框中选择 NTLauncher.ico(这个文件来自 0xEEEE 在逆向调用QQ截图NT与WeChatOCR 提供的 QQScreenShotNT-Lite.zip 压缩包)

在 Add a new resource 对话框,Neutral value 就是刚刚选择的文件,Store as 下拉框选择 System.Drawing.Icon (Windows Forms),其他保持默认

设置应用程序图标
这部分参考指定应用程序图标 (Visual Basic, C#)。在解决方案资源管理器中,右键单击 NTLauncher 项目,选择 Properties

选中 Application > Win32 Resources,点击 Browse 按钮

在文件选择对话框中选择 NTLauncher.ico,注意选择文件所在的目录,这个文件是上一步添加图标资源添加到项目的 Resources 目录的

文件选择完成后 Icon 项的值为 Resources\NTLauncher.ico

添加系统托盘和右键菜单
这部分主要参考了以下资料
- NotifyIcon 组件概述(Windows 窗体)
- 如何使用 Windows 窗体 (Windows Forms) NotifyIcon 组件向任务栏添加应用程序图标
- 如何:将快捷菜单与 Windows 窗体 NotifyIcon 组件相关联
- 如何将 ContextMenuStrip 与控件关联
- 如何:向 ContextMenuStrip 添加菜单项
- NotifyIcon 类
- ContextMenuStrip 类
- winform实现最小化至系统托盘
- C#实现 Winform 程序在系统托盘显示图标 & 开机自启动
- WinForm窗体隐藏任务栏图标和系统托盘显示图标
添加 NotifyIcon 和 ContextMenuStrip 控件
在 Toolbox 搜索 NotifyIcon,将 NotifyIcon 控件拖入 Form1

对 ContextMenuStrip 控件进行类似的操作,控件添加完成后

配置 NotifyIcon 图标
选中 notifyIcon1 控件,在 Properties 找到 Appearance > Icon,点击右侧 … 按钮

在文件选择对话框中选择 NTLauncher.ico,注意选择文件所在的目录,这个文件是在添加图标资源添加到项目的 Resources 目录的

配置 NotifyIcon 右键菜单
选中 notifyIcon1 控件,在 Properties 找到 Behavior > ContextMenuStrip,在右侧下来选项中选择 contextMenuStrip1

配置 NotifyIcon 其他属性
选中 notifyIcon1 控件,配置 Appearance > Text 为 NTLauncher,配置 Behavior > Visible 为 True

notifyIcon1 的 Visible 配合 Form1 的 ShowInTaskbar 和 WindowState 就可以实现启动应用程序只显示托盘图标的功能。
添加右键菜单项
选中 contextMenuStrip1 控件,点击右上角三角形图标,在弹出的 ContextMenuStrip Tasks 中点击 Edit Items

在 Items Collection Editor 对话框选中 contextMenuStrip1 成员,选择 MenuItem,点击 Add 按钮添加菜单项

在 Items Collection Editor 对话框选中 contextMenuStrip1 成员,选择 Separator,点击 Add 按钮添加分割线

我们将添加 7 个菜单项,3 条分割线,通过右侧的功能按钮排序或删除

选中 toolStripMenuItem1,修改 Appearance > Text 的值为关于

对其他菜单项进行类似操作,编辑完成后的 Form1

使应用程序只显示托盘图标
选中 Form1 控件,设置 Layout > WindowState 为 Minimized,设置 Window Style > ShowInTaskbar 为 False

添加托盘退出事件
选中 contextMenuStrip1 控件,然后选中退出菜单项,配置 Action > Click 为 toolStripMenuItem7_Click

上面的操作会自动在 Form1.cs
中创建函数 toolStripMenuItem7_Click
,其实现为
1 | private void toolStripMenuItem7_Click(object sender, EventArgs e) |
添加设置对话框
这部分内容主要参考了以下资料
- C# Winform同一子窗体只允许打开一次
- WinForm窗体应用——父窗体每次只打开一个子窗体的方法
- c# WinForm 点击出现弹出多个窗体, 怎么才能只显示一个窗体。解决方案
- C# WinForm 点击按钮显示唯一窗体
- C#窗体程序(winform)禁止最小化、最大化,或去掉关闭按钮
- WinForm 设置窗体启动位置在活动屏幕右下角
新增设置对话框
在 NTLauncher 项目上单击右键,在弹出菜单中依次选择 Add > New Items

在 Add New Item 对话框中选中 Installed > C# Items,在右侧选中 Form (Windows Forms),注意下方 Name 的值 Form2.cs

点击设置菜单项显示设置对话框
在 Form1 选中 contextMenuStrip1 控件,然后选中设置菜单项,配置 Action > Click 为 toolStripMenuItem2_Click

上面的操作会自动在 Form1.cs
中创建函数 toolStripMenuItem2_Click
,其实现为
1 | private Form2? form2 = null; |
在这个实现里增加了一个变量 form2
来表示打开的设置对话,对话框关闭时把该变量置空,这保证多次点击设置菜单只打开一个对话框。
配置设置对话框样式
选中 Form2,设置 Appearance > Text 为 设置,设置 Window Style > MaximizeBox 为 False,设置 Window Style > MinimizeBox 为 False,设置 Window Style > ShowIcon 为 False,设置 Window Style > ShowInTaskbar 为 False

设置对话框启动位置在屏幕右下角
选中 Form2,设置 Layout > StartPosition 为 Manual

在 Form2.cs
中重新计算对话框的位置
1 | public Form2() |
绘制对话框
这部分内容主要参考了以下资料
- 教程:使用 .NET 创建 Windows 窗体应用
- 教程:创建数学测验 WinForms 应用
- 控件的位置和布局(Windows 窗体 .NET)
- 演示:使用捕捉线排列 Windows 窗体上的控件
- 如何:在 Windows 窗体上对齐多个控件
- Winform禁止调整大小
主要用到的控件为 GroupBox、CheckBox、Label、TextBox、Button,成品图如下所示

选中 Form2,设置 Appearance > FormBorderStyle 为 FixedSingle,禁止改变窗口大小

创建配置文件
在 NTLauncher 项目上单击右键,在弹出菜单中依次选择 Add > New Items

在弹出的 Add New Item 对话框中选中 Installed > C# Items > General,然后选中 Text file,修改 Name 处的名称为 config.ini

文件的内容来自 QQScreenShotNT-Plus 的初始配置
1 | [ExePath] |
参考在 .NET 项目中复制资源文件夹到生成目录,在 Solution Explorer 中选中 config.ini 文件,在 Properties 中修改 Advanced > Copy to Output Directory 为 Copy always

在 Visual Studio 中通过这种方式创建的文件的格式是 UTF-8 with BOM,在使用 kernel32.dll 中的 GetPrivateProfileString
、WritePrivateProfileString
读写时无法读写第一节的内容。有两种避免的方式,第一种是参考 C#读写ini配置文件 和 INI文件使用时的注意事项让 ini 文件的第一行为空行。第二种是重新创建一个 UTF-8 without BOM 格式的文件。这里选择第三种方式,直接使用 QQScreenShotNT-Plus 中的 config.ini 文件,它是 UTF-8 without BOM 格式的文件。
读写配置文件
添加配置类
在 NTLauncher 项目上单击右键,在弹出菜单中依次选择 Add > New Items

在弹出的 Add New Item 对话框中选中 Installed > C# Items > Code,然后选中 Class,修改 Name 处的名称为 Config.cs

Config.cs 文件的内容如下所示,config.ini 中的每一个键对应一个属性
1 | namespace NTLauncher |
添加读写配置的工具类
这部分内容参考以下资料
- C# 读写编辑INI文件-完整详细版
- c# winform程序读写ini配置文件
- .NET(C#)读写ini配置文件的方法及示例代码
- C#读取写入ini配置文件-以winform为例
- 使用类和对象探索面向对象的编程
使用 kernel32.dll 中的 GetPrivateProfileString
、WritePrivateProfileString
方法读写 config 配置文件
1 | using System.Runtime.InteropServices; |
读取配置文件
在 Form1.cs 的构造方法中读取配置文件并保存到全局变量中
1 | private ConfigHandler handler; |
写入配置文件
这部内容参考以下资料
把配置信息传递给对话框
通过构造函数把配置信息传递给对话框。改造 Form2.cs 的构造函数,增加 Config config
参数,在构造函数里为表单项赋值
1 | private Config config; |
在点击设置菜单时把配置传递给对话框,如下面的第 5 行代码
1 | private void toolStripMenuItem2_Click(object sender, EventArgs e) |
把配置信息传递给主窗口
为 Form2 的确定按钮添加点击事件。在 Form2 选中确定按钮,在 Properties 修改 Action > Click 为 button6_Click

在 button6_Click
方法中收集表单的值并对 this.config
相关属性赋值
1 | private void button6_Click(object sender, EventArgs e) |
在打开对话框的地方判断结果是否为 DialogResult.OK
,如果是,则把配置信息写回配置文件,如下面代码第 11~14 行所示
1 | private void toolStripMenuItem2_Click(object sender, EventArgs e) |
我们 Form1 和 Form2 共享同一个 config 变量,这可能不是一种好的做法!!!
为对话框的取消按钮添加事件
选中 Form2 的取消按钮,在 Properties 中修改 Action > Click 为 button7_Click

在 button7_Click
方法中设置 this.DialogResult
为 DialogResult.Cancel
1 | private void button7_Click(object sender, EventArgs e) |
此时取消按钮的功能和窗口的关闭按钮一致。
添加 QQImpl 动态链接库
从 MMMojoCall v3.0.0 下载 x64-Release.zip,解压后将 MMMojoCall.dll 复制到 NTLauncher 项目目录。在 Solution Explorer 选中 MMMojoCall.dll,在 Properties 设置 Advanced > Copy to Output Directory 为 Copy always

从 QQImpl 下载 parent-ipc-core-x64.dll 并将其复制到 NTLauncher 项目目录。在 Solution Explorer 选中 parent-ipc-core-x64.dll,在 Properties 设置 Advanced > Copy to Output Directory 为 Copy always

编译 QQImpl 动态链接库
这部分内容参考了以下资料
从 QQImpl Releases 下载的 MMMojoCall.dll 不包含 qq_mojoipc,可以通过如下的方式进行验证。在 Visual Studio 菜单栏选择 View > Terminal 进入命令提示符

进入 NTLauncher 项目所在的目录 D:\projects\dotnet\QQScreenShotLauncher\NTLauncher 执行 dumpbin /EXPORTS .\MMMojoCall.dll
命令,从结果中未能找到 qq_mojoipc 相关的内容。对 QQScreenShotNT-Plus 使用的 MMMojoCall.dll 执行相同的命令,从结果中可以找到 qq_mojoipc 相关的内容,下面是节选的部分内容
1 | 20 13 00015A70 ?ConnectedToChildProcess@QQIpcParentWrapper@qqipc@qqimpl@@QEAA_NH@Z |
因此,我们需要重新编译 QQImpl 项目。
首先,从 QQImpl 克隆项目,比如克隆到 D:\projects\dotnet
目录,当前最新的提交为 3aac297
。解压 3rdparty 目录下的 3rdparty.7z 文件

解压 xplugin_protobuf 目录下的 google.zip 文件

从 CMake 网站下载 cmake-3.31.6-windows-x86_64.msi 进行安装,在终端输入 cmake -version
验证安装是否成功。在终端进入 D:\projects\dotnet\QQImpl\MMMojoCall 目录,输入如下命令生成解决方案
1 | cmake -B build -G "Visual Studio 17 2022" -A x64 -DXPLUGIN_WRAPPER=ON -DBUILD_QQIPC=ON -DBUILD_CPPEXAMPLE=ON -DEXAMPLE_USE_JSON=ON -DBUILD_PURE_C_MODE=ON |
等待命令执行完成后,使用 Visual Studio 打开 build 目录,即 D:\projects\dotnet\QQImpl\MMMojoCall\build,中的解决方案

在弹出的 Open Project/Solution 对话中选择刚生成的解决方案

打开解决方案后在工具栏选择 Release

随后在菜单栏选择 Build > Build Solution 菜单

编译完成后进入 D:\projects\dotnet\QQImpl\MMMojoCall\build\Release 目录,用编译好的 MMMojoCall.dll 替换 QQScreenShotLauncher 中的同名文件

我们也可以使用 dumpbin /EXPORTS .\MMMojoCall.dll
命令检查是否有 qq_mojoipc 相关的内容
1 | Microsoft (R) COFF/PE Dumper Version 14.41.34120.0 |
包装 QQImpl 动态链接库
创建动态链接库
在 Solution Explorer 选中 QQScreenShotLauncher 解决方案,在右键菜单中选择 Add > New Project

在 Add a new project 对话框选择 C++、Windows、Library、Dynamic-Link Library (DLL)

在 Configure your new project 对话框填写 Project name 为 MMMojoCallWrapper,Location 保持默认

添加 .h 和 .lib 文件
在 QQImpl 项目找到 qq_ipc.h 文件,把它复制到 D:\projects\dotnet\QQScreenShotLauncher\MMMojoCallWrapper 目录

在 QQImpl 项目找到 MMMojoCall.lib 文件,把它复制到 D:\projects\dotnet\QQScreenShotLauncher\MMMojoCallWrapper 目录

在 Solution Explorer 选中 MMMojoCallWrapper 项目的 Header Files,在右键菜单选择 Add > Existing Item

在 Add Existing Item 对话框选择刚刚添加的 qq_ipc.h 文件

对 qq_ipc.h 头文件依赖的 mojo_call_export.h 头文件进行相同的操作。
添加动态链接库包装
在 Solution Explorer 选中 MMMojoCallWrapper 项目,在右键菜单中选择 Add > Class

在 Add Class 对话框输入类名 MMMojoCallWrapper,其他保持默认

下面的内容参考以下资料
MMMojoCallWrapper.h 文件的内容如下所示
1 |
|
MMMojoCallWrapper.cpp 文件的内容如下所示
1 |
|
编译 MMMojoCallWrapper 项目,在终端进入 D:\projects\dotnet\QQScreenShotLauncher\x64\Debug 目录执行 dumpbin /exports .\MMMojoCallWrapper.dll
命令
1 | Microsoft (R) COFF/PE Dumper Version 14.41.34120.0 |
实现 QQ 截图功能
这部分内容参考以下资料
添加项目依赖
在 Solution Explorer 选中 NTLauncher 项目,在右键菜单中选择 Build Dependencies > Project Dependencies

在弹出的 Project Dependencies 对话框中选中 MMMojoCallWrapper

选中 NTLauncher 项目,在右键菜单中选择 Properties

选中 Build > Events,在 Post-build event 输入 xcopy /y /d "$(SolutionDir)x64\$(Configuration)\$(IntDir)MMMojoCallWrapper.dll" "$(OutDir)"

实现动态链接库包装
在 Solution Explorer 选中 NTLauncher 项目,在右键菜单中选择 Add Class

在弹出的 Add New Item 对话框中创建一个 Class,名称为 QQIpcWrapper.cs

QQIpcWrapper.cs 的实现如下所示,注意以下事项:
void*
在 C# 中通常是IntPtr
。- 请求参数
char*
映射为string
,并默认认定为 UTF-8 字符串。如果需要其他编码,可以进一步处理。 - 响应参数为
bool
时需要加上[return: MarshalAs(UnmanagedType.I1)]
标记,表示 C++ 的bool
只占一个字节。 CallbackIpc
使用[UnmanagedFunctionPointer]
指定调用约定为CallingConvention.Cdecl
。cmdlines
参数为char**
,可以传递一个string[]
,由 C# 处理自动封送到非托管代码中。- 对
QQIpcParentWrapper_GetLastErrStr
函数的响应参数采用IntPtr
是因为该函数返回的是一个指向 C++ 字符串的指针 (const char*
)。在 C# 中,我们无法直接对指针类型的数据进行处理,并且不能知道返回的字符串是如何管理内存的(例如:是否是动态分配的、是否需要释放)。因此将返回值指定为IntPtr
,并根据需要在 C# 中手动解析它为字符串。返回的字符串以 ANSI 编码表示时使用Marshal.PtrToStringAnsi(ptr)
,返回的字符串以 UTF-8 编码表示时使用Marshal.PtrToStringUTF8(ptr)
(适用 .NET 5 或更高版本)。
1 | using System; |
定义 IPC 回调
在 Form1.cs 中定义 IPC 回调
1 | private QQIpcWrapper.CallbackIpc callbackIpc = (IntPtr pArg, string msg, int arg3, string addition_msg, int addition_msg_size) => |
目前忽略所有的回调消息。
初始化 QQ 截图进程
在 Form1.cs 的构造函数中初始化 QQ 截图进程,我们定义两个变量 IntPtr parent
和 int pid
用来保存实例化后的 QQIpcParentWrapper
和子进程的进程 ID
1 | private IntPtr parent; |
点击托盘图标开启截图
在 Form1.cs 选中 notifyIcon1,然后在 Properties 设置 Action > MouseClick 为 notifyIcon1_MouseClick

notifyIcon1_MouseClick 方法的实现如下所示,发送给子进程的命令为 screenShot
,命令内容来自 QQScreenShotNT-Lite
项目
1 | private void notifyIcon1_MouseClick(object sender, MouseEventArgs e) |
退出应用时结束子进程
修改 Form1.cs 中的方法 toolStripMenuItem7_Click,退出应用时结束子进程并清理资源,如第 3~10 行所示
1 | private void toolStripMenuItem7_Click(object sender, EventArgs e) |
实现热键截图功能
这部分内容参考以下资料
由于 Windows Forms 没有提供全局热键功能,因此要使用 user32.dll。在 NTLauncher 中创建 HotKey.cs
1 | using System; |
在 Form1.cs 的构造函数中注册热键,变量 ID_SCREENSHOTHOTKEY
的值来自 QQScreenShotNT-Lite
项目
1 | private int ID_SCREENSHOTHOTKEY = 61166; |
重写窗体的处理函数,把热键消息拿出来单独处理
1 | protected override void WndProc(ref Message m) |
退出应用程序时取消热键,如下面代码的第 5 行所示
1 | private void toolStripMenuItem7_Click(object sender, EventArgs e) |
实现清空截图缓存功能
实现退出时自动清空截图缓存需要修改 toolStripMenuItem7_Click 方法
1 | private void toolStripMenuItem7_Click(object sender, EventArgs e) |
实现点击清空截图缓存按钮清空截图缓存需要在 Form2.cs 选中清空截图缓存按钮,修改 Action > MouseClick 为 button5_MouseClick

button5_MouseClick 方法的实现如下所示
1 | private void button5_MouseClick(object sender, MouseEventArgs e) |
实现启动成功气泡提示
这个功能非常简单,只需要在 Form1.cs 的构造函数的最后增加如下代码即可,如第 5~8 行所示
1 | public Form1() |
实现开机自启功能
这部分功能参考以下资料
当前选择通过注册表的方式来实现开机自启功能,因此需要在 Form1.cs 引入 using Microsoft.Win32;
,在保存配置时判断开启自启选项是否勾选,如果勾选则注册注册表,否则删除注册表,如下代码第 13~70 行所示
1 | private void toolStripMenuItem2_Click(object sender, EventArgs e) |