Electron for 鸿蒙PC - Native模块Mock与降级策略

前言

在将 MarkText 适配到鸿蒙 PC 平台时,我们遇到了一个严重的阻碍:多个 Native 模块无法编译 。MarkText 依赖的 keytar(密码管理)、native-keymap(键盘映射)、ced(编码检测)等 C++ 扩展模块在鸿蒙 PC 上全部编译失败,导致应用无法启动。

本文将详细记录我们如何通过 Mock 和降级策略完美解决这一问题,在不修改原有业务逻辑的前提下,让应用在鸿蒙 PC 上正常运行。

关键词 :鸿蒙PC、Electron适配、Native模块、C++ Addon、Mock、降级策略、跨平台兼容

目录

  1. 鸿蒙PC的Native模块问题
  2. Node.js模块加载机制
  3. Mock策略设计
  4. 完整实现方案
  5. 降级功能实现
  6. 总结与展望

鸿蒙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 关键技术点

  1. Module._load 拦截:模块加载的统一入口
  2. 降级实现:用 JS 实现替代 Native 模块
  3. 加密存储:安全的本地凭证管理
  4. 条件 Mock:仅在需要的平台启用

6.3 适用场景

这套方案不仅适用于鸿蒙 PC,也适用于:

  • ✅ 任何 Native 模块无法编译的平台
  • ✅ 需要快速适配新平台的场景
  • ✅ 测试环境(无需安装 Native 依赖)
  • ✅ 容器化部署(避免编译问题)

6.4 Mock vs 重新编译

方案 优点 缺点 推荐度
Mock 快速、无需编译、跨平台 功能可能受限 ⭐⭐⭐⭐⭐
重新编译 功能完整 需要平台支持、工作量大 ⭐⭐

建议:优先尝试 Mock,只有在功能严重受限时才考虑重新编译。

6.5 源码地址

完整代码已开源在 MarkText for HarmonyOS 项目中:


相关资源

Node.js 官方文档

Electron 官方文档

鸿蒙PC开发资源


技术难度:⭐⭐⭐⭐ 中高级

实战价值:⭐⭐⭐⭐⭐ 解决鸿蒙PC Native模块核心障碍

推荐指数:⭐⭐⭐⭐ 跨平台适配重要技能

相关推荐
颜酱1 小时前
package.json 配置指南
前端·javascript·node.js
珑墨1 小时前
【唯一随机数】如何用JavaScript的Set生成唯一的随机数?
开发语言·前端·javascript·ecmascript
用户463989754322 小时前
Harmony os——AbilityStage 组件管理器:我理解的 Module 级「总控台」
harmonyos
隔壁的大叔2 小时前
如何自己构建一个Markdown增量渲染器
前端·javascript
WILLF2 小时前
HTML iframe 标签
前端·javascript
用户463989754322 小时前
Harmony os——UIAbility 组件基本用法:启动页、Context、终止与拉起方信息全流程
harmonyos
用户463989754322 小时前
Harmony os——启动应用内的 UIAbility:跨 Ability 跳转、回传结果 & 指定页面全流程
harmonyos
用户463989754322 小时前
Harmony os——UIAbility 组件生命周期|我按自己的理解梳了一遍
harmonyos
ohyeah3 小时前
JavaScript 词法作用域、作用域链与闭包:从代码看机制
前端·javascript