2025版Nodejs基于ffi-napi实现调用windows API

如题,我想实现的效果是:Electron桌面应用实时监测用户聚焦的软件,将正在使用的软件名称展示给用户并同步到Web服务端。本文只展示Nodejs脚本如何基于ffi-napi实现调用windows API获取用户聚焦的软件名称并展示给用户,不提及与Web服务端的交互(实际上就是省略了签名认证发包同步数据的过程)。

这个需求来自于我正在开发的小项目WhatAmIDoing,等基本功能完成时估计会另写篇文章说说实现思路,项目地址:kinoko/WhatAmIDoin · GitHub(虽然现在是private)

这次经历略显折磨,Electron桌面开发是我之前没太涉及过的领域,然后环境配置的关键步骤正好又没什么完善的文档,恰好配置过程中各种关于环境版本的小坑还不少,所以折腾了一天。虽然最后降低了node版本有点委曲求全,但所幸还是解决了问题。

关键应用及版本:

  • windows10
  • Microsoft Visual Studio2022
  • node v16.17.0 | npm v8.15.0 | pnpm v6.35.1
  • electron@34
  • python 3.12.7

网上不少相关文章会要求降低node版本到node@12,我没搞清楚为什么要降低这么多,我个人是在node@16即可成功安装。不过我也暂时没找到在node@18+版本中使用ffi-napi的方法。

node为什么不能直接调用windows api

简单来说就是封装了太多层,为了跨平台的特性舍弃了直接调用windows api的能力。

先有的javascript再有的nodejs,而javascript被发明之初也只是为了能实现浏览器html页面的简单交互,为了简单易用甚至于放弃了class而转用原型链,这你就不能指望js本身有能和windows api直接交互的能力。而nodejs本身又是基于js运行时的环境,外面套了层引擎,比如著名的v8,更追求跨平台运行的能力,所以也不会说把调用windows api的能力放到首位。

javascript运行原理可以先看看我这篇博客:JavaScript执行原理,不算很细,但做做了解也问题不大

windows api这种本身也就能和c/cpp等语言对接,后来开发者觉得不太行,nodejs作为服务端语言竟然不能和windows交互,所以ffi-napi这类库应运而生,实现nodejs调用dll的能力。

环境配置-安装ffi-napi

说是环境配置,其实重点就一个,那就是怎么安装ffi-napi,网上也能搜到很多相关文章,但我没找到一篇涵盖所有坑的,我是在东拼西凑了几个解决方案之后才成功安装ffi-napi的。

这里先给出注意事项清单,下面会接着说明为什么要这么配置、怎么配置(有必要的话)以及关键步骤截图,整体来说其实不麻烦,甚至可以说简单,但如果你不了解这个领域同时网上还一堆杂七杂八抄来抄去的资料干扰视线时,这也可以变成一件麻烦事:

  • 安装python,node-gyp要用
  • 安装node-gyp,这个是npm自带的,问题是最新版的ffi-napi目前只兼容了node-gyp<10的版本,而>=18的npm自带的node-gyp>=10,所以为了能用ffi-napi,得降低node版本到node16,我使用的LTS版本是node@16.17.0
  • 通过visual studio installer安装C++桌面开发相关库
  • 通过visual studio installer安装Spectre v14.2 x86/64相关库
  • 通过visual studio installer安装windows SDK,并且要和windows版本对应,注意msvs2022默认安装windows11 SDK,如果是你是win10则需要另外安装windows10 SDK
  • npm设置msvs版本变量npm config set msvs_version 2022 --global
  • msbuild安装(msvs自带)并添加系统/用户环境变量

ok,大致了解之后,现在开始。

安装python

windows10如何安装python就直接略过了,网上很多成熟的教程。

你可能搜到过一些文章,告诉你安装ffi-napi需要python2.x,比如这篇:node-ffi从入门到放弃(安装篇) - 威武的大萝卜 - 博客园

按照我的步骤走的话不需要降低python版本到2.x,我直接用的我之前安装过的python3.12,上述那篇文章是2022年发布的,应该是比较保守一些,整体环境版本都相对较老(比如文中提及的msvs_version 2017),估计是低版本的node-gyp仍然只支持使用python2.x的构建工具。

想了解了解可以看这篇文章:npm安装某些模块为什么需要python – PingCode

npm在安装某些模块时需要Python是因为一些依赖包或者模块需要通过node-gyp进行编译,而node-gyp是一个跨平台的命令行工具,它依赖于Python来执行一系列构建操作。这些操作涉及到编译C或C++代码,主要是因为npm的一些包含原生C/C++扩展的模块需要编译成机器码,这样才能在特定平台上运行,提高执行效率、直接操作硬件资源、实现与操作系统底层的交互

降低node/npm版本适配node-gyp

使用nvm安装node/npm,可以使用windows包管理器直接安装nvm,不需要手动安装:

1
scoop install nvm

然后windows下的nvm常见指令就不说了,看看就能明白,也可以去看我之前的文章。

然后问题回到ffi-napi的安装上。如果你使用的是node>=18的版本,对应版本的npm安装ffi-napi时会报错,其中有关键语句时:

1
'"call"' 不是内部或外部命令,也不是可运行的程序或[批处理文件]

这是因为node-gyp>10的版本不支持call命令,所以需要降低node-gyp版本;而npm install时使用的node-gyp库是npm自带的,可以理解为npm的依赖项,据我目前了解应该是没有单独降低npm内置node-gyp版本的方法,所以就需要降低npm版本。

降低到node@16.17.0即可,对应npm@8.15.0。使用以下指令查看全局node-gyp的版本,

1
npm list -g node-gyp

image.png

通过msvs安装各种环境及工具

如前文所说,node-gyp需要环境和工具去编译cpp/c相关库,所以我们这里需要自备cpp变异环境和部分工具;我们需要调用windows API,所以我们也要安装windows SDK。而这些环境及工具都可以通过Microsoft Visual Studio Installer安装。

  • 使用C++的桌面开发(MSBuidl等工具)
  • windows 10 SDK
  • MSVC v142
  • Spectre v14.2

image.png

勾选图中这些框选的选项就可以,简单来说就是配置node-gyp需要的编译构建环境和工具。

添加msbuild环境变量

用户Path变量

1
C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe

系统Path变量

1
C:\Windows\Microsoft.NET\Framework64\v4.0.30319

npm设置msvs_version全局变量

1
npm config set msvs_version 2022 --global

ok,以上步骤搞定之后,在对应项目中安装ffi-napi就不会报错了。下面我根据需求自定义ddl并在nodejs脚本中调用。

编写js脚本验证可实现node调用自定义的dll

dll(动态链接库)是一种包含可由多个程序共享的代码和数据的文件格式。它允许程序在运行时动态加载和链接库中的功能,而不是在编译时将所有代码静态链接到可执行文件中。这种机制使得程序可以节省内存,提高效率,并且便于更新和维护。

Windows API是微软为Windows操作系统提供的一组接口,允许开发者与操作系统进行交互。许多Windows API函数都是以dll的形式提供的,这意味着开发者可以通过调用这些DLL中的函数来实现各种操作系统功能,如文件管理、图形界面、网络通信等。

编写dll

敲代码之前先介绍点相关概念。我之前接触的Web开发中的很多模块是经过了层层封装的,和相对底层的部分有些距离。

主要就是介绍句柄 - 维基百科,自由的百科全书,可以先看看wiki.

句柄(Handle)是一个用于标识和管理系统资源的抽象引用。它通常是一个整数或指针,代表操作系统中的某个对象,如窗口、文件、进程、线程等。通过句柄,程序可以对这些资源进行操作,而无需直接访问它们的内存地址。

根据我们要实现的功能,这里自定义dll要做的事情就是先获取用户聚焦窗口的句柄HWND,然后基于窗口句柄获取进程ID,进而得到进程句柄HANDLE获取需要的软件名称。

启动msvs,创建基于cpp的dll项目,创建后有四个主要的文件,

  1. dllmain.cpp:这是dll的入口点文件,包含了dll的初始化和清理代码。它定义了DllMain函数,该函数在dll被加载或卸载时被调用。你可以在这里执行一些初始化操作,比如分配资源,或者在dll卸载时释放资源。
  2. framework.h:这个头文件通常用于定义项目中使用的常量、宏、数据结构和函数声明。它可以包含其他必要的头文件,提供项目所需的基础功能。
  3. pch.cpp:这是预编译头文件的实现文件。预编译头可以加快编译速度,尤其是在包含大量头文件的项目中。这个文件通常包含了在pch.h中声明的内容。
  4. pch.h:这是预编译头文件,通常包含了项目中常用的头文件和库的引用。通过使用预编译头,可以减少编译时间,提高开发效率。

要实现的功能比较简单,且我们目前只关注功能的实现,所以只修改dllmain.cpp即可,相关代码的功能以注释的形式放在代码块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include "pch.h"
#include <windows.h>
#include <string>
#include <psapi.h>

//**
// 导出函数,获取当前活动窗口的应用程序名称
// **/
extern "C" __declspec(dllexport) const char* GetActiveWindowAppName() {
// 获取当前活动窗口的句柄
HWND hwnd = GetForegroundWindow();
if (hwnd == NULL) {
return "No active window";
}

// 获取活动窗口所属的进程ID
DWORD processID;
GetWindowThreadProcessId(hwnd, &processID);

// 打开进程以查询信息和读取内存
HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processID);
if (hProcess == NULL) {
return "Unable to open process";
}

// 获取进程的可执行文件名,因为获取到的是绝对路径,所以需要进行处理
char processName[MAX_PATH];
if (GetModuleFileNameExA(hProcess, NULL, processName, sizeof(processName) / sizeof(char))) {
std::string fullPath(processName);
std::string appName = fullPath.substr(fullPath.find_last_of("\\") + 1);
appName = appName.substr(0, appName.find_last_of("."));

CloseHandle(hProcess);
return _strdup(appName.c_str());
}

CloseHandle(hProcess);
return "Unable to get process name";
}

//**
// dll的入口点
// **//
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

msvs生成解决方案即可,这个构建的很快,然后输出中会直接给出绝对路径,后面要用(当然你也可以挪来挪去,但我觉得有绝对路径能cv了也很方便)。

image.png

编写js脚本调用dll检验结果

这里就不用create-electron-app这种库拉取electron项目模板了,手动创建项目,安装依赖。

1
pnpm install --save-dev electron ffi-napi ref-napi

我的package.json如下,其中"start": "electron ."暂时用不到,下一小节创建electron应用的时候使用,对接下来的js脚本验证可实现node调用自定义的dll无影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "electron ."
},
"author": "",
"license": "ISC",
"devDependencies": {
"electron": "^34.0.0",
"ffi-napi": "^4.0.3",
"ref-napi": "^3.0.3"
}
}

然后编写满足文章开头所说需求的脚本,调用生成好的dll文件监测本地用户聚焦的软件,注意替换dll路径即可(我设置的轮询间隔为2s)

1
2
3
4
5
6
7
8
9
10
11
12
const ffi = require('ffi-napi');

const userTracking = ffi.Library('C:\\your-path-to-dll\\UserTracking.dll', {
'GetActiveWindowAppName': ['string', []]
});

const printActiveAppName = () => {
const appName = userTracking.GetActiveWindowAppName();
console.log(appName);
};

setInterval(printActiveAppName, 2000);

然后node运行该文件,观察控制台,可以看到脚本正在运行,监测你正在用的软件

image.png

以上。