前言
在将 MarkText 适配到鸿蒙 PC 平台时,我们遇到了一个严重的阻碍:多个 Native 模块无法编译 。MarkText 依赖的 keytar(密码管理)、native-keymap(键盘映射)、ced(编码检测)等 C++ 扩展模块在鸿蒙 PC 上全部编译失败,导致应用无法启动。
本文将详细记录我们如何通过 Mock 和降级策略完美解决这一问题,在不修改原有业务逻辑的前提下,让应用在鸿蒙 PC 上正常运行。
关键词 :鸿蒙PC、Electron适配、Native模块、C++ Addon、Mock、降级策略、跨平台兼容

目录
鸿蒙PC的Native模块问题
1.1 错误现象
MarkText 首次在鸿蒙 PC 上编译时,出现大量错误:
bash
# keytar 编译失败
npm ERR! gyp ERR! build error
npm ERR! gyp ERR! stack Error: `make` failed with exit code: 2
npm ERR! gyp ERR! stack at ChildProcess.onExit
# native-keymap 编译失败
Error: Module did not self-register
# ced 编译失败
Error: Cannot find module '../build/Release/ced.node'
表现:
- ❌ 应用启动时崩溃
- ❌ 控制台报错:
Cannot find module 'keytar' - ❌ 即使跳过错误,相关功能完全不可用
1.2 受影响的Native模块
| 模块 | 用途 | 影响功能 | 鸿蒙PC状态 |
|---|---|---|---|
| keytar | 系统密码管理(Git凭证) | Git集成 | ❌ 无法编译 |
| native-keymap | 键盘布局检测 | 快捷键 | ❌ 无法编译 |
| ced | 字符编码检测 | 文件打开 | ❌ 无法编译 |
| vscode-ripgrep | 高性能文件搜索 | 全局搜索 | ⚠️ 路径问题 |
1.3 为什么无法编译
Native模块特点:
- 用 C/C++ 编写,需要编译成
.node文件 - 依赖特定的系统 API(如 Windows Credential Manager、macOS Keychain)
- 需要平台特定的编译工具链
鸿蒙PC的问题:
标准平台:
Windows → 有 Credential Manager API
macOS → 有 Keychain API
Linux → 有 libsecret API
鸿蒙PC:
❌ 没有对应的系统 API
❌ 编译工具链不完整
❌ 某些系统调用不支持
结论 :这些模块在鸿蒙 PC 上无法编译,也无法运行。
Node.js模块加载机制
2.1 模块加载流程
require('keytar')
↓
Module._resolveFilename() // 查找模块路径
↓
Module._load() // 加载模块(关键!)
↓
Module._extensions['.node']() // 处理 .node 文件
↓
process.dlopen() // 加载动态链接库
↓
返回模块 exports
关键点 :Module._load() 是所有模块加载的入口,可以拦截!
2.2 拦截模块加载
javascript
const Module = require('module')
const originalLoad = Module._load
// 重写 _load 方法
Module._load = function(request, parent, isMain) {
console.log('加载模块:', request)
// 自定义逻辑
if (request === 'keytar') {
return mockKeytar // 返回 Mock 对象
}
// 调用原始加载逻辑
return originalLoad(request, parent, isMain)
}
Mock策略设计
3.1 三种Mock策略
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 完全Mock | 可选功能 | 返回空实现,静默失败 |
| 降级实现 | 核心功能 | 用 JS 实现替代方案 |
| 错误提示 | 必需功能 | 抛出友好错误信息 |
3.2 MarkText各模块策略
| 模块 | 功能重要性 | 采用策略 | 理由 |
|---|---|---|---|
| keytar | 中等(Git凭证) | 降级实现 | 用本地加密文件替代 |
| native-keymap | 低(键盘布局) | 完全Mock | 返回默认美式键盘 |
| ced | 低(编码检测) | 完全Mock | 默认UTF-8 |
| vscode-ripgrep | 中等(搜索) | 路径修复 | 提供默认路径 |
完整实现方案
4.1 Preload脚本拦截
javascript
// preload-harmonyos.js
const Module = require('module')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const os = require('os')
console.log('[Preload] 开始设置 Native 模块 Mock')
/**
* keytar 降级实现(使用本地加密文件)
*/
class KeytarFallback {
constructor() {
this.storePath = path.join(os.homedir(), '.marktext-credentials')
this.encryptionKey = this.getOrCreateKey()
}
getOrCreateKey() {
const keyPath = path.join(os.homedir(), '.marktext-key')
if (fs.existsSync(keyPath)) {
return fs.readFileSync(keyPath, 'utf-8')
}
// 生成新密钥
const key = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(keyPath, key, { mode: 0o600 })
console.log('[KeytarFallback] 已生成新密钥')
return key
}
encrypt(text) {
const cipher = crypto.createCipher('aes-256-cbc', this.encryptionKey)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return encrypted
}
decrypt(encrypted) {
const decipher = crypto.createDecipher('aes-256-cbc', this.encryptionKey)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
loadStore() {
if (!fs.existsSync(this.storePath)) {
return {}
}
try {
const encrypted = fs.readFileSync(this.storePath, 'utf-8')
const decrypted = this.decrypt(encrypted)
return JSON.parse(decrypted)
} catch (error) {
console.error('[KeytarFallback] 加载失败:', error)
return {}
}
}
saveStore(store) {
try {
const json = JSON.stringify(store)
const encrypted = this.encrypt(json)
fs.writeFileSync(this.storePath, encrypted, { mode: 0o600 })
} catch (error) {
console.error('[KeytarFallback] 保存失败:', error)
}
}
async getPassword(service, account) {
console.log('[KeytarFallback] getPassword:', service, account)
const store = this.loadStore()
const key = `${service}:${account}`
return store[key] || null
}
async setPassword(service, account, password) {
console.log('[KeytarFallback] setPassword:', service, account)
const store = this.loadStore()
const key = `${service}:${account}`
store[key] = password
this.saveStore(store)
return true
}
async deletePassword(service, account) {
console.log('[KeytarFallback] deletePassword:', service, account)
const store = this.loadStore()
const key = `${service}:${account}`
delete store[key]
this.saveStore(store)
return true
}
async findCredentials(service) {
console.log('[KeytarFallback] findCredentials:', service)
const store = this.loadStore()
const credentials = []
for (const [key, password] of Object.entries(store)) {
if (key.startsWith(service + ':')) {
const account = key.substring(service.length + 1)
credentials.push({ account, password })
}
}
return credentials
}
async findPassword(service) {
console.log('[KeytarFallback] findPassword:', service)
const credentials = await this.findCredentials(service)
return credentials.length > 0 ? credentials[0].password : null
}
}
/**
* Native 模块 Mock 映射表
*/
const nativeModuleMocks = {
// keytar - 使用降级实现
'keytar': new KeytarFallback(),
// native-keymap - 完全 Mock
'native-keymap': {
getKeyMap: () => {
console.warn('[Mock] native-keymap.getKeyMap 被调用')
return {
layout: 'en-US',
keys: {}
}
},
getCurrentKeyboardLayout: () => {
console.warn('[Mock] native-keymap.getCurrentKeyboardLayout 被调用')
return 'en-US'
},
onDidChangeKeyboardLayout: (callback) => {
console.warn('[Mock] native-keymap.onDidChangeKeyboardLayout 被调用')
return { dispose: () => {} }
}
},
// ced - 完全 Mock
'ced': {
detect: (buffer) => {
console.warn('[Mock] ced.detect 被调用')
return {
encoding: 'UTF-8',
confidence: 100
}
},
detectSync: (buffer) => {
console.warn('[Mock] ced.detectSync 被调用')
return {
encoding: 'UTF-8',
confidence: 100
}
}
},
// vscode-ripgrep - 路径修复
'vscode-ripgrep': {
rgPath: '/usr/bin/rg',
__esModule: true,
default: {
rgPath: '/usr/bin/rg'
}
}
}
/**
* 拦截 Module._load 实现 Native 模块 Mock
*/
const originalLoad = Module._load
Module._load = function(request, parent, isMain) {
// 检查是否需要 Mock
if (nativeModuleMocks[request]) {
console.log('[Mock] 使用 Mock 模块:', request)
return nativeModuleMocks[request]
}
// 尝试加载原始模块
try {
return originalLoad(request, parent, isMain)
} catch (error) {
// 如果加载失败,检查是否是已知的 Native 模块
const knownNativeModules = [
'keytar',
'native-keymap',
'ced',
'vscode-ripgrep',
'node-pty',
'fsevents'
]
if (knownNativeModules.some(mod => request.includes(mod))) {
console.error('[Mock] Native 模块加载失败:', request)
console.error('[Mock] 错误信息:', error.message)
// 返回一个空对象,避免应用崩溃
console.warn('[Mock] 返回空对象作为降级')
return {}
}
// 其他错误,继续抛出
throw error
}
}
console.log('[Preload] Native 模块 Mock 已启用 ✓')
4.2 条件Mock(仅鸿蒙平台)
javascript
// preload.js
const os = require('os')
// 检测是否是鸿蒙平台
const isHarmonyOS = process.platform === 'linux' &&
os.release().includes('ohos')
if (isHarmonyOS) {
console.log('[Preload] 检测到鸿蒙平台,启用 Native 模块 Mock')
// 加载 Mock 逻辑
require('./native-mock-harmonyos')
} else {
console.log('[Preload] 标准平台,使用原生 Native 模块')
}
4.3 主进程配置
javascript
// main.js
const { app, BrowserWindow } = require('electron')
const path = require('path')
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// 关键:加载带 Mock 的 preload 脚本
preload: path.join(__dirname, 'preload-harmonyos.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(createWindow)
降级功能实现
5.1 keytar降级方案详解
原理:用本地加密文件替代系统密码管理器
文件结构:
~/.marktext-key # AES密钥(权限600)
~/.marktext-credentials # 加密的凭证存储
加密流程:
明文密码
↓ AES-256-CBC 加密
加密数据
↓ 写入文件(权限600)
~/.marktext-credentials
安全性:
- ✅ 使用 AES-256 加密
- ✅ 密钥文件权限限制(仅所有者可读)
- ✅ 凭证文件权限限制
- ⚠️ 不如系统密码管理器安全,但可接受
5.2 实际使用效果
Git凭证存储测试:
javascript
// 业务代码(无需修改)
const keytar = require('keytar')
// 保存Git凭证
await keytar.setPassword('marktext-git', 'github.com', 'ghp_xxxxx')
// 读取Git凭证
const token = await keytar.getPassword('marktext-git', 'github.com')
console.log('Token:', token) // 正常工作!
// 删除凭证
await keytar.deletePassword('marktext-git', 'github.com')
结果:
- ✅ API 完全兼容,业务代码无需修改
- ✅ 功能正常,Git 集成可用
- ✅ 数据持久化,重启后仍然有效
5.3 native-keymap Mock效果
javascript
// 业务代码
const nativeKeymap = require('native-keymap')
// 获取键盘布局
const layout = nativeKeymap.getCurrentKeyboardLayout()
console.log('键盘布局:', layout) // 输出: en-US
// 监听布局变化
const disposable = nativeKeymap.onDidChangeKeyboardLayout(() => {
console.log('键盘布局已变化')
})
结果:
- ✅ 不会崩溃
- ✅ 返回默认值(en-US)
- ⚠️ 无法检测实际键盘布局(可接受)
遇到的坑与解决方案
6.1 坑1:Mock时机问题
问题:在应用代码之后 Mock,模块已经被加载。
解决方案:
javascript
// ✅ 正确:在 preload.js 最开始 Mock
// preload.js 第一行
const Module = require('module')
Module._load = function(...) { ... }
// 然后才加载其他模块
const { contextBridge } = require('electron')
6.2 坑2:加密文件权限
问题:Windows 上文件权限设置不生效。
解决方案:
javascript
// 跨平台权限设置
if (process.platform === 'win32') {
// Windows 使用 ACL
const { execSync } = require('child_process')
execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`)
} else {
// Unix-like 使用 chmod
fs.chmodSync(filePath, 0o600)
}
6.3 坑3:模块路径匹配
问题:某些模块通过相对路径引用,匹配失败。
javascript
// 业务代码可能这样引用
require('../node_modules/keytar')
require('../../node_modules/keytar')
解决方案:
javascript
Module._load = function(request, parent, isMain) {
// 规范化模块名
const moduleName = request.split('/').pop().split('\\').pop()
if (nativeModuleMocks[moduleName]) {
return nativeModuleMocks[moduleName]
}
return originalLoad(request, parent, isMain)
}
6.4 坑4:异步Mock
问题:某些 Mock 方法需要返回 Promise。
解决方案:
javascript
// ✅ 正确:返回 Promise
async getPassword(service, account) {
return await someAsyncOperation()
}
// 或者
getPassword(service, account) {
return Promise.resolve(result)
}
总结与展望
6.1 成果总结
通过 Native 模块 Mock 和降级策略,我们成功解决了 MarkText 在鸿蒙 PC 上的编译和运行问题:
✅ 完全解决了 Native 模块无法编译的问题
✅ 应用可以正常启动和运行
✅ 核心功能保持可用 (Git 集成、文件操作等)
✅ 零侵入性 (业务代码无需修改)
✅ 性能无影响(Mock 开销可忽略)
6.2 关键技术点
- Module._load 拦截:模块加载的统一入口
- 降级实现:用 JS 实现替代 Native 模块
- 加密存储:安全的本地凭证管理
- 条件 Mock:仅在需要的平台启用
6.3 适用场景
这套方案不仅适用于鸿蒙 PC,也适用于:
- ✅ 任何 Native 模块无法编译的平台
- ✅ 需要快速适配新平台的场景
- ✅ 测试环境(无需安装 Native 依赖)
- ✅ 容器化部署(避免编译问题)
6.4 Mock vs 重新编译
| 方案 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| Mock | 快速、无需编译、跨平台 | 功能可能受限 | ⭐⭐⭐⭐⭐ |
| 重新编译 | 功能完整 | 需要平台支持、工作量大 | ⭐⭐ |
建议:优先尝试 Mock,只有在功能严重受限时才考虑重新编译。
6.5 源码地址
完整代码已开源在 MarkText for HarmonyOS 项目中:
- 项目地址:https://gitcode.com/szkygc/marktext
- 关键文件 :
preload-harmonyos.js- Native 模块 Mock 实现native-mock-harmonyos.js- Mock 逻辑封装
相关资源
Node.js 官方文档:
Electron 官方文档:
鸿蒙PC开发资源:
技术难度:⭐⭐⭐⭐ 中高级
实战价值:⭐⭐⭐⭐⭐ 解决鸿蒙PC Native模块核心障碍
推荐指数:⭐⭐⭐⭐ 跨平台适配重要技能