开源鸿蒙PC原生适配:Node版本管理器命令行移植
开源鸿蒙PC原生适配:Node版本管理器命令行移植
摘要:本文记录了把 Windows 上的 Node 版本管理器 nodist 移植到开源鸿蒙(HarmonyOS NEXT / OpenHarmony PC)的完整过程。移植走原生路线:ArkTS + ArkUI 负责界面,C/C++ 通过 NAPI 承载 shim 机制、版本决议和 execv 进程替换。文章包含工程结构、六个关键步骤和十个真实踩过的坑,最后给出可复现的构建与验证流程。目前已在 phone、tablet、2in1 三类设备形态的模拟器上跑通,CLI 形态可以在设备 shell 里完成真实的版本切换与参数透传。
相关地址:
- 适配仓库(开源鸿蒙 PC 社区):https://atomgit.com/OpenHarmonyPCDeveloper/ohos_nodist
- 上游原版项目:https://github.com/nodists/nodist(MIT 协议,作者 Marcel Klehr)
- 开源鸿蒙 PC 社区入口:https://atomgit.com/OpenHarmonyPCDeveloper
- 社区新建项目申请页:https://atomgit.com/OpenHarmonyPCDeveloper/community/issues/create
环境搭建部分社区已有同学写过,可以参考这篇:鸿蒙 PC 环境搭建。
这篇文章偏工程复盘。不像 Electron 应用移植那样要处理 renderer、Node 网络栈和一堆运行时依赖,nodist 是个命令行工具,它的难点集中在“进程替换”这件事能不能在鸿蒙的沙箱和构建体系里原样跑起来。
一、为什么给 nodist 选原生路线
nodist 和常见桌面软件不太一样。它干的活是拦截 node 命令,按 .node-version 决定用哪个版本,然后把当前进程替换成真正的 node 可执行文件。核心是 execv,不是界面。
适配前我把三条路线摆在一起对比过:
| 路线 | 做法 | 是否保留 execv 语义 | 包体 | 结论 |
|---|---|---|---|---|
| Electron 壳 | 把原版 JS 逻辑整体塞进鸿蒙 Electron 运行时 | 可以 | 大 | nodist 本身没有 GUI,为它套一个 Electron 壳不划算 |
| 纯 ArkTS 重写 | 全部逻辑用 ArkTS 实现 | 不行,拿不到进程替换能力 | 小 | 丢掉了项目最核心的机制 |
| ArkTS + C/C++ NAPI | 界面用 ArkUI,核心逻辑放原生层 | 保留 | 小 | 采用 |
决定因素只有一个:execv 这类系统调用必须落在原生层。ArkTS 侧能写文件、能发起进程,但没有等价的进程替换原语。所以界面和逻辑被切成了两层,中间用 NAPI 连起来。
顺带一个好处:main.cpp 单独编译成一个可执行文件,这个二进制可以经 hdc 推到设备上,在 shell 里以最接近原版的方式运行。GUI 负责演示和日常操作,CLI 负责还原度。
二、上游的 shim 机制
先把要还原的东西说清楚。原版 nodist 的关键逻辑大致是三步:
// 1. 决议版本:读当前目录的 .node-version,没有就用默认值
std::string resolveVersion();
// 2. 拼出真实 node 的路径,确认存在
std::string realNodePath = ".../nodist_versions/v" + version + "/node";
// 3. 把自己的 argv[0] 换成真 node 的路径,然后进程替换
argv[0] = realNodePath;
execv(realNodePath.c_str(), argv);
第 3 步是整个项目存在的理由。execv 之后当前进程镜像被替换,argv[1] 往后的参数一个都没动过,所以参数是百分之百透传的,不经过任何 shell 解析。任何“用子进程转发参数”的实现都会在这里失真。
在鸿蒙上还原这套东西,坑大多不在逻辑本身,而在工程链路和运行时环境上。下面按实际顺序讲。
三、工程结构
适配后的目录大致是这样:
| 路径 | 作用 |
|---|---|
AppScope/ | 应用级配置、分层图标 |
entry/src/main/ets/pages/Splash.ets | 启动页,1.5 秒后进主界面 |
entry/src/main/ets/pages/Index.ets | Nodist 控制台界面 |
entry/src/main/ets/entryability/EntryAbility.ets | UIAbility 生命周期 |
entry/src/main/cpp/napi_init.cpp | NAPI 导出,界面调的四个命令都在这里 |
entry/src/main/cpp/main.cpp | CLI 二进制入口,shim 与 execv |
entry/src/main/cpp/CMakeLists.txt | 同时编共享库和可执行文件 |
一个模块里编出两个产物、走两条运行路径,这是整个工程的基本形态。
四、适配路径
4.1 从一个崩溃开始:NAPI 库根本没编出来
第一次在模拟器上运行,界面出来是好的,点一下文字整个应用就没了:
Reason:TypeError
Error message:Cannot read property add of undefined
Stacktrace: at anonymous entry (entry/src/main/ets/pages/Index.ets:19:84)
翻 hilog 能看到更直接的一行:
W C03f04/MMG: key:default/entry First: failed Error loading shared library libentry.so:
No such file or directory.Second: load module default/entry failed
E C03f00/ArkCompiler: export objects of native so is undefined, so name is @normalized:Y&&&libentry.so&
libentry.so 压根不在 HAP 里。原因在 CMakeLists.txt:它只有一行
add_executable(nodist main.cpp)
napi_init.cpp 没有被任何 target 引用,所以在构建过程中从来没被编译过,ArkTS 侧的 import testNapi from 'libentry.so' 自然解析不到,testNapi 是 undefined,一点就抛异常。未捕获的异常直接把进程带走。
修法是补回 NAPI 目标,同时保留可执行文件:
cmake_minimum_required(VERSION 3.4.1)
project(Nodist)
add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC libace_napi.z.so)
add_executable(nodist main.cpp)
改完重新构建,产物里能看到两个目标:
entry/build/default/intermediates/libs/default/x86_64/libentry.so
entry/build/default/intermediates/cmake/default/obj/x86_64/nodist
顺手在 ArkTS 侧给原生调用加了判空。原生模块没加载时打日志提示,而不是让异常把应用杀掉。这类“先把 UI 保住”的兜底在做移植时很有必要,因为原生层的失败往往是运行时的,编译期看不出来。
4.2 把界面换成命令控制台
原版模板是个 Hello World。既然要演示命令,界面就按控制台来组织:版本输入框、几个命令按钮、一排 node 参数输入、底部输出区。命令统一走一个执行函数,输出直接追加到页面文本上:
private runAction(label: string, action: () => string): void {
this.appendConsole(`> ${label}`);
if (!this.nativeReady()) {
this.appendConsole('[错误] libentry.so 原生模块未加载');
return;
}
try {
this.appendConsole(action());
} catch (err) {
this.appendConsole('[异常] ' + JSON.stringify(err));
}
}
这样界面不需要知道每个命令的内部细节,原生层返回什么就显示什么。
4.3 回车触发与键码的坑
控制台没有回车很难用,于是给输入框挂 onSubmit 和 onKeyEvent,后者作为物理键盘的兜底,再加一个 500ms 防抖避免两条路径同时命中。
写完发现按回车没反应。查日志能看到用户回调其实被调用了:
OnKeyEventUser: Node TextInput/31 handle KeyEvent(secure_field, 0) return: 0
问题在判定条件上。我一直按 Android 的习惯写 keyCode === 66,但在鸿蒙 SDK 里定义是:
KEYCODE_ENTER = 2054
KEYCODE_NUMPAD_ENTER = 2119
66 不是这里任何一个值,所以判断永远为假。另外模拟器注入的按键事件 type 是 0,而不是 KeyType.Down。最后判定写成:
private isEnterDown(event: KeyEvent): boolean {
const isDown = event.type === KeyType.Down || event.type === 0;
return isDown &&
(event.keyCode === KeyCode.KEYCODE_ENTER || event.keyCode === KeyCode.KEYCODE_NUMPAD_ENTER);
}
这里有个经验:跨平台写输入处理,别照搬记忆里的键码常量,直接去 SDK 的类型声明里查一遍。ets/api/@ohos.multimodalInput.keyCode.d.ts 里写着确切的值。
4.4 2in1 上的空白启动画面
手机上启动时系统会显示一个启动窗口,图标居中。切到 2in1(鸿蒙 PC)模拟器上,启动画面变成一大片白,中间什么都没有。
我先按常规思路把 startIcon.png 从 144×144 放大到 512×512,重新构建安装,还是白的。为了确认图标到底有没有被渲染,我把 startIcon.png 临时换成一张 1024×1024 的纯洋红色块,然后在整个屏幕范围内扫描洋红像素,结果是零。
再看 hilog:
SetSessionInfoStartWindowType: id:94, startWindowType:0
GetStartWindowColorFollowApp: follow sys: com.example.nodist
startWindowType:0 表示这个设备形态上系统不显示启动窗口图标。PC 端的窗口模型和手机不一样,指望系统启动窗口来做品牌露出是不成立的。
最后一个纯粹为了测量启动窗口行为的临时改动帮了忙:在 loadContent 前加一个 3 秒延时,把那段时间的布局 dump 出来,看到启动页的 Image 元素实际是 [1017,469][2104,1556],也就是 1087×1087 像素。
结论是启动画面要自己画。方案是加一个 Splash.ets 页面,大 Logo 居中,1.5 秒后 router.replaceUrl 进主界面,系统启动窗口只当作过渡。同时把 deviceTypes 从 ["phone"] 扩到 ["phone", "tablet", "2in1"],否则在 PC 上根本装不上。
4.5 release 混淆把原生导出名改掉了
上架前打开混淆(entry/build-profile.json5 里 buildOptionSet.release.arkOptions.obfuscation.ruleOptions.enable 改成 true),release 包装上一测,所有命令按钮全部报错:
> nodist -v
[异常] {}
[异常] {} 是 JSON.stringify(err) 的输出,说明抛的是没有可枚举属性的对象,典型情况是调用了 undefined。
混淆产物里给了确切答案。entry-nameCache.json:
"PropertyCache": {
"nodistInstall": "d1",
"nodistRun": "e1",
"nodistVersion": "f1",
"nodistList": "g1"
}
属性混淆把 testNapi.nodistInstall 改成了 testNapi.d1,而原生模块注册的属性名还是 nodistInstall,两边对不上。有意思的是 add 没被改名,所以自检按钮是好的,只有那几个命令挂了。
处理方式是在 obfuscation-rules.txt 里保留这些属性名:
-enable-property-obfuscation
-enable-toplevel-obfuscation
-enable-filename-obfuscation
-enable-export-obfuscation
-keep-property-name
add
nodistVersion
nodistInstall
nodistList
nodistRun
改完再构建,这几个名字从 PropertyCache 里消失了,说明没被改名;装到设备上四个命令都正常。
这条经验可以推广:只要 ArkTS 和 C/C++ 之间有按名字约定的接口,混淆规则里就得把这些名字列进去。编译期不会报错,只有在跑起来调用时才炸。
4.6 版本号是外部输入,要当成不可信数据处理
nodist + <version> 接收用户输入的版本号,而这个字符串会出现在两个危险位置:
- 拼进文件路径:
<baseDir>/nodist_versions/v<version>/node。输入../../../xxx就能写到目录外面去。 - 拼进 shim 脚本内容:脚本里有一行
echo "[真实 Node.js v<version>] ..."。这个脚本稍后会被 shell 执行,输入里带引号、反引号或者$(...)就能往里注入命令。
第二条比第一条严重,因为它最终落在 shell 的执行路径上。修法是加白名单,只允许数字、字母、点、短横线和下划线,且必须含数字:
static bool IsValidVersion(const std::string& version)
{
if (version.empty() || version.size() > 32) return false;
bool hasDigit = false;
for (char c : version) {
bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || c == '.' || c == '-' || c == '_';
if (!ok) return false;
if (c >= '0' && c <= '9') hasDigit = true;
}
return hasDigit && version != "." && version != "..";
}
在安装和运行的入口都过一遍。实测输入 ../../evil 会被拦下并给出提示,不再产生任何文件。
五、踩坑汇总
把上面这些和排查过程中顺手记下的问题放在一起:
| 现象 | 根因 | 处理 |
|---|---|---|
点界面就崩,Cannot read property add of undefined | CMakeLists 只编了可执行文件,libentry.so 没进 HAP | 补 add_library(entry SHARED napi_init.cpp) 并链接 libace_napi.z.so |
DevEco 构建报 Cannot find module '@ohos/hvigor' | 用户级 hvigor 缓存里的 workspace node_modules 为空 | 补齐或删除该缓存目录,让 hvigor 重新引导 |
| 回车不触发命令 | 鸿蒙 KEYCODE_ENTER 是 2054,不是 Android 的 66 | 用 SDK 里的枚举常量比较,别写数字 |
| 模拟器注入按键无反应 | 注入事件 type 为 0,不等于 KeyType.Down | 判定时把 0 也算作按下 |
| 2in1 启动画面全白 | 该设备形态 startWindowType:0,系统不渲染启动窗口图标 | 改用应用内 Splash 页面 |
| release 包所有命令报错 | 属性混淆把 NAPI 导出名改成了 d1/e1/f1/g1 | 在混淆规则里 -keep-property-name 保留导出名 |
应用内运行 node 报 Permission denied | 应用沙箱的 SELinux 策略禁止 spawn shell | 捕获 execv 失败并提示走 hdc;CLI 二进制不受限 |
| 版本号可写出目录、注入脚本 | 外部输入直接参与路径拼接和脚本内容生成 | 白名单校验,只放行数字字母和 .-_ |
.node-version 读到奇怪的值 | 文件带 UTF-8 BOM 或 v 前缀 | 解析时先剥离 BOM,再剥 v 前缀,最后校验 |
| 桌面图标还是旧的 | launcher 图标缓存不随重装刷新(模拟器现象) | 真机或清除数据后重装即可,与打包无关 |
六、沙箱差异:手机、PC 与 hdc
.node-version 与 shim 逻辑在应用内和 shell 里表现不同,这点值得单独说。
应用内执行 node 时,原生层会 fork 再 execv("/system/bin/sh", ...)。在手机模拟器上直接失败:
[execv 失败] Permission denied
[exit code 127]
这是应用沙箱的 SELinux 策略所致,不是代码问题。同样的代码在 2in1 模拟器上可以跑通,说明不同设备形态的策略并不一致:
> node
[真实 Node.js v18.16.0] 收到执行指令。参数列表:
[exit code 0]
所以应用内的“运行”能力要当成可能不可用来设计:失败时给出明确的替代路径(用 hdc 推二进制到设备执行),而不是只丢一个错误码。
真正的还原度验证走 CLI:
hdc file send <构建产物>/obj/x86_64/nodist /data/local/tmp/nodist
hdc shell chmod 755 /data/local/tmp/nodist
hdc shell /data/local/tmp/nodist -v
hdc shell /data/local/tmp/nodist + 18.16.0
以 node 名义调用,触发进程替换:
hdc shell cp /data/local/tmp/nodist /data/local/tmp/node
hdc shell "cd /data/local/tmp && echo 18.16.0 > .node-version && ./node --version"
输出:
[真实 Node.js v18.16.0] 收到执行指令。参数列表: --version
未安装的版本会被拦下:
Nodist: 无法执行,版本 v20.0.0 尚未安装。请使用 nodist + 20.0.0 进行安装。
到这一步,版本决议、进程替换、参数透传三条都验证过了。

七、构建与验证
DevEco 里打开工程,Sync 完成后配置签名直接 Run 就行。命令行构建需要把 DevEco 自带的 jbr 和 node 配进环境:
export JAVA_HOME="<DevEco>/jbr"
export PATH="<DevEco>/tools/node:$PATH"
export DEVECO_SDK_HOME="<DevEco>/sdk"
# debug
hvigorw assembleHap --mode module -p product=default --no-daemon
# release(带混淆)
hvigorw assembleHap --mode module -p product=default -p buildMode=release --no-daemon
产物在 entry/build/default/outputs/default/。发布包要解锁一件事:确认 libs/ 目录下两个架构的 libentry.so 都在,release 包里还应该看不到 sourceMaps.map。
tar -tf entry-default-unsigned.hap | grep -E 'libs|abc'
libs/arm64-v8a/libentry.so
libs/x86_64/libentry.so
ets/modules.abc
八、运行效果


九、后续计划
- 版本决议补上
NODIST_VERSION环境变量,和上游行为对齐 - 接真实 Node 二进制的下载、校验与安装,目前的安装是生成 shim 脚本,用来验证流程
- npm 能力接入
- 补齐 CI 构建
十、参考资料
- 上游项目 nodist:https://github.com/nodists/nodist
- 开源鸿蒙 PC 社区:https://atomgit.com/OpenHarmonyPCDeveloper
- 源码混淆官方说明:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation
- 编译构建常见问题:https://developer.huawei.com/consumer/cn/doc/harmonyos-faqs/faqs-compiling-and-building
- 鸿蒙 PC 环境搭建(社区文章):https://blog.csdn.net/lbcyllqj/article/details/161286249
- Electron 项目适配实例参考:https://blog.csdn.net/lbcyllqj/article/details/161957882
更多推荐



所有评论(0)