把 Zettlr 搬上鸿蒙 PC:一次 Electron 应用移植实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_zettlr
本文记录将开源 Markdown 学术写作工具 Zettlr 适配到 HarmonyOS PC 的完整过程:从 libelectron 底座搭建、跨过 SIGTRAP 启动崩溃、注入真实编辑器引擎,到像素级打磨深色主题。所有坑都是真机复现、逐一定位的,文中附修复前后的可量化数据。


图 0:DevEco Studio 中的工程与构建产物
一、缘起:为什么是 Zettlr
鸿蒙 PC 起步阶段,桌面应用生态里最缺的就是"生产力刚需":办公套件有了,IDE 有了,但一款像样的 Markdown 学术写作工具一直空缺。Zettlr 是这个领域的明星开源项目------面向学术写作、采用 Zettelkasten 卡片盒笔记法、支持引用管理和多格式导出,由德国开发者 Hendrik Erz 维护,技术栈是 Electron + Vue 3 + TypeScript + CodeMirror 6。
适配目标很明确:在鸿蒙 PC 上跑"真正的 Zettlr",而不是用 ArkTS 重写一个长得像它的替代品 。这句话后来成为整个项目所有技术决策的锚点------每当我犹豫"要不要干脆简化重写"时,都回到这条原则上来。

二、技术选型:三条路的取舍
Electron 应用上鸿蒙,摆在面前的路有三条:
路线 A:ArkTS 全面重写。 工作量以人年计,且写完之后它就不再是 Zettlr 了------上游演进无法跟进,等于造了一个一次性分叉。否决。
路线 B:远程 Web 化。 把前端挂到服务器上用浏览器访问。体验依赖网络,且原生文件系统能力全丢。否决。
路线 C:复用 OpenHarmony 官方定制的 Electron 运行时(openharmony-sig/electron)。 它把整个 Electron 打成了 libelectron.so,主进程、渲染进程、IPC、BrowserWindow 全部保留真实语义。代价是体积------单个 so 就有 177MB------但对"跑真 Zettlr"这个目标来说,这是唯一正路。
选 C。架构上就是一个双模块 HAP:
electron/(entry 模块):塞libelectron.so+ 宿主 Ability,来自官方移植,不绑定具体应用;web_engine/(HAR):Electron 适配框架 + 应用资源,Zettlr 的一切都放在它的resfile/resources/app/下。
主进程入口 main.cjs 有三项防坑设计,全部对齐官方 demo 的最佳实践:
- GPU 彻底禁用必须最前置 ------
app.disableHardwareAcceleration()加五个--disable-gpu*开关,放在任何窗口创建之前,否则在 EGL 路径上白屏或反复崩溃; - 渲染进程隔离 ------
contextIsolation: true、nodeIntegration: false,所有原生能力经 preload 桥接; - 全局错误落盘 ------鸿蒙端没有 DevTools,
uncaughtException必须写进userData/zettlr-runtime.log才能事后排查。
这一步做完,占位前端跑通,Electron 路线验证成立。当时觉得最难的部分已经过去了------天真。
三、第一记闷棍:SIGTRAP
真机安装成功、aa start 返回 successfully,然后进程直接崩了:
Reason: Signal:SIGTRAP(TRAP_BRKPT)
#04 libelectron.so(ElectronMain+380)
崩在 ElectronMain 初始化极早期,且模拟器和真机的崩溃偏移逐字节一致 (0x581a19c)------这说明不是环境问题,是确定性的 CHECK 失败。
接下来是漫长的排除法:
- 安装冲突? 首次安装报
install entry already exist (code 9568267)------设备上残留着旧 ArkTS 版 Zettlr,entry 模块名不同导致冲突。卸载重装后过了这关,但崩溃依旧。 - 设备形态?
const.product.devicetype = 2in1,deviceTypes配置正确。排除。 - GPU? 壳工程
CommandLineAdapter已内置--use-gl=disabled等全套开关。排除。 - SDK 版本? 从 6.0.1(21) 对齐到设备的 6.1.0(23),仍崩。排除。
- 应用代码? 把
main.cjs精简到"只创建窗口 + loadFile",仍崩;且主进程日志一条都没输出------崩溃发生在 JS 逻辑之前。
然后做了一个当时以为很聪明的对照实验:装官方最小 Electron demo 到同一台设备。结果它也崩:
Reason:Signal:SIGABRT(SI_TKILL)
APPSPAWN: [appspawn_isolate.c:215] ioctl ADD_ISOLATE_DIR_CMD fail, errno 22
我据此下了结论:"该系统版本不支持 Electron 的多进程沙箱模型"。这个判断写进了文档,还规划了"等上游修复"的后续动作。
四、破案:对照实验的对照组本身就是错的
转折点很偶然。整理文件时发现,同一台电脑上存在两份不同批次下载的 libelectron 包------而我一直用来做"决定性对照"的官方 demo,恰好用的是旧批次。
真正的关键事实是:两份不同版本的 libelectron.so(本工程 160MB / 官方 demo 152MB)在鸿蒙 6.1.0.x 上都会崩,但它们都是旧版。真正可用的新版(对应 5.0.x 之后的新 NAPI 桥接架构)从来没进过对照实验。
根因 :旧版 libelectron 是 5.0.x 时代 NAPI 桥接架构的产物,与鸿蒙 6.1.0.x 系统的 NAPI 协议不兼容,在 ElectronMain 初始化早期触发 CHECK 失败。此前"模拟器不支持""真机不支持"的结论全部是错误归因------对照组本身就是坏的。
这个教训值得单独写下来:对照实验只能证明"两组表现一致",不能证明"两组都正常"。如果对照组本身就是坏的,一致性只会把你引向更自信的错误结论。
为避免后人踩同一个坑,我总结了一个三十秒判别法:
bash
ls electron/src/main/ets/entryability/
# 旧版:只有 EntryAbility 等极简结构(3 个文件)
# 新版:StatusBarEntryAbility / TaskManagerAbility 等完整多窗口架构(5 个)
五、换底座与嫁接
确认根因后,修复方案不是改代码,而是整体替换底座 :把新版 libelectron 的 ohos_hap 用 rsync 整体覆盖过来(排除 build/),再把 Zettlr 的定制内容一项项嫁接回去:
AppScope/app.json5:bundleName=org.zettlr.ohos、vendor=zettlr、multiAppMode.maxCount=1;AppScope/resources/base/media/:Zettlr 绿 Z 图标三件套;- 两处
string.json:品牌文案; resfile/resources/app/:Zettlr 的index.html/main.cjs/package.json/preload.cjs;build-profile.json5:换成绑定org.zettlr.ohos的签名证书。
旧底座完整备份一份再动手------这个习惯后来救了命(排查时反复对比新旧差异)。
换完底座,紧接着撞上权限墙 。新底座 web_engine 的 requestPermissions 里有三个受限 ACL 权限:SYSTEM_FLOAT_WINDOW、WEB_NATIVE_MESSAGING、kernel.ALLOW_WRITABLE_CODE_MEMORY。安装直接报 code:9568289 grant request permissions failed------证书没有对应 ACL 授权。
分析后三个权限 Zettlr 都用不上(悬浮窗、浏览器扩展通信、JIT 可写内存),直接删除,安装通过。这里有个取舍值得记录:JIT 可写内存对大型 JS 应用有性能意义,如果将来需要,要在 DevEco 的 Signing Configs 里重新申请 ACL,而不是硬留着装不上。
再然后是 preload 坑:应用的 package.json 声明了 "type": "module",于是 .js 后缀的 preload 被当 ESM 解析,里面的 require('electron') 直接报 require is not defined。解法土但有效:改名 preload.cjs,同步改主进程引用路径。
至此,真机上完整跑通:主进程、GPU、NetworkService、Renderer 四进程全部存活,app ready → createWindow → loadFile → did-finish-load 全链路日志无错,窗口渲染并可交互。
六、最难的部分:注入真实 Zettlr 编辑器引擎
底座通了,接下来回答核心问题:Zettlr 本体怎么进来?
完整移植先做了可行性评估,结论是不可行。Zettlr 完整应用依赖 20+ 主进程服务,其中:
| 依赖 | 死因 |
|---|---|
nodehun(拼写检查) |
C++ 原生 Node addon,只有 glibc/Windows/macOS 预编译产物;OHOS 是 musl libc,无对应二进制,也没有交叉编译链 |
| Pandoc(导出) | 外部可执行二进制,沙箱不可执行 |
| citeproc(引用管理) | 依赖完整文献数据库后端 |
| SQLite 等原生模块 | 同 nodehun |
换一条思路:从 Zettlr 源码里找唯一一块纯 TypeScript、零原生依赖的核心 。答案是 source/common/modules/markdown-editor------用户实际输入、看到语法高亮/实时渲染/表格编辑器的那部分,纯 TS + CodeMirror 6。一行不改 ,用 webpack 单独打包成 file:// 环境可加载的 bundle,接到自建的主进程 IPC 上。
这条路听起来干净,实际走过去踩了六个坑:
坑 1:window.ipc 未定义。 markdown-editor 有多个文件在模块顶层 (不是函数内)直接调 ipcRenderer.on('dictionary-provider', ...),桩没打就会在 import 时炸。globals.ts 必须在 import MarkdownEditor 之前把 window.ipc = { on, send, invoke } 和 window.config = { get, set, on } 全部备好。
坑 2:桩函数返回值类型。 LanguageTool 初始化会 for (const word of dictionary) 遍历 invoke 的返回值。桩函数笼统返回 undefined 直接炸 for...of------必须针对 channel 按 command 返回类型正确的空数组。
坑 3:process is not defined。 contextIsolation 下渲染进程没有 Node 的 process,而 Zettlr 代码里有 process.platform 判断。webpack 加 ProvidePlugin({ process: require.resolve('process/browser.js') })------必须用 require.resolve 拿绝对路径且带 .js 后缀,否则严格 ESM 里解析报错。
坑 4:单文件 bundle 打不出来。 代码里大量 import() 动态导入,webpack 无论如何都会拆 chunk;试图用 splitChunks: false 强压,结果每个 chunk 重复打包公共依赖、体积反而暴涨。正确做法是保留默认拆分、设 output.publicPath: '',把 65 个 chunk 全带上 ,webpack runtime 在 file:// 下能正常按需注入。
坑 5:CodeMirror 没有 content setter。 编程式写入内容要用公开的 editor.replaceSelection(text)(走真实 CM6 transaction),不要碰内部 DOM。
坑 6:协同编辑接口。 DocumentAuthorityAPI 的 OT 接口在单机场景简化掉:pullUpdates 返回一个永不 resolve 的 Promise(没有远端协作者),pushUpdates 直接 async () => true,持久化改走 'change' 事件 + 防抖 + 自己的 writeFile IPC。
最终产物约 6MB(主 bundle 3.6MB + 65 个按需 chunk),对比整个 Zettlr 完整应用,缩小了一个数量级。
七、真机验证:不靠猜坐标的自动化冒烟
功能验证用了 CDP(Chrome DevTools Protocol):remote-debugging-port 起调试口,hdc fport 端口转发,用 Node 原生 WebSocket 发 Runtime.evaluate,依次驱动"新建 → 编辑 → 另存为 → 重新打开 → 再编辑 → 保存"全流程。
系统原生对话框 CDP 驱动不了,于是加了一个仅测试期存在 的路径白名单开关,跳过"点对话框选文件"这一步,下游 100% 走真实 fetchDoc/readFile/writeFile/CodeMirror 渲染代码。
这里有个非常隐蔽的坑:沙箱路径双视角 。app.getPath('userData') 在应用内部视角是 /data/storage/el2/base/files,而 hdc shell 在外部视角看同一份数据是 /data/app/el2/100/base/org.zettlr.ohos/files。两种表述不能互换。测试脚本必须用应用自己汇报的内部路径去调 API,用外部路径去 hdc shell cat 做验证。
而验证的关键设计是独立信道交叉验证 :不能只看应用自己说"保存成功",要用 hdc shell cat 直接读磁盘、比对文件内容。当独立的通道确认磁盘上的文件内容和编辑器状态一致时,才能证明数据真的持久化了,而不是只活在内存或 JS 状态里。验收后所有调试后门全部移除,生产包不含任何调试口子。

图 1:真机运行,深色主题下的编辑器

图 2:Markdown 语法高亮,标题/代码/链接分色渲染
八、最后一公里:把"看不清"量化成数字
功能全部跑通后,最后暴露的是视觉问题:正文一片灰,看不清。
第一反应是"改个颜色的事",真去改才发现水深。Zettlr bundle 里硬编码了默认配置:
js
darkMode: false, // 浅色
darkModeEditor: "match", // 编辑器跟随 darkMode
theme: "berlin"
主题解析函数 kae(darkMode, darkModeEditor) 拿到 false,加载 berlin 浅色 变体------正文色约 #aaa 配白底,WCAG 对比度只有约 2.1:1 ,而无障碍标准 AA 线是 4.5:1。更尴尬的是外壳 index.html 的 body 是深色 #1e1e1e,于是整个窗口上深下浅,像穿了半截衣服。
还有个死胡同先排掉:试着调 window.config.set('darkMode', true)------没用。反编译 bundle 发现它监听 darkMode 变更后只去重初始化 mermaid 图表 的主题(aoe.initialize),编辑器主题压根不走这个通道。Zettlr 桌面版的主题切换走的是 Settings UI 那套完整的编辑器重初始化,我们没有那套后端。
所以解法只能是外层 CSS 强制覆盖 :在 index.html 用 #editor-container 作用域加 !important,把 .cm-editor / .cm-scroller / .cm-content / .cm-line 的背景压到 var(--bg)、文字提到 var(--fg),连带着行号槽、当前行、光标、选区、搜索面板、tooltip 箭头全套处理。
第一版方案在这里翻了个跟头。语法高亮的 token 上不了深色配色,我图省事用了滤镜兜底:
css
filter: invert(0.88) hue-rotate(180deg);
真机一看:所有 token 被统一染成黄绿色,蓝色通道被压到 27~60,关键字、字符串、注释全是同一个色------对比度够了(约 6:1),但语法高亮的区分度全废了,而且那片黄绿实在辣眼睛。
教训:invert 滤镜只适合"能看就行"的兜底,不适合要保留语义的配色。 第二版老老实实写了一套仿 VS Code Dark+ 的语义色:标题蓝 #569cd6、链接青 #4ec9b0、行内代码橙 #ce9178、引用绿 #6a9955、列表紫 #c586c0,Markdown 语法符号(#、*、`````)统一压暗到 #6e7681 让正文更突出。
效果验证也不用肉眼吵------截屏 + 像素分析给出硬数据:
- 背景众数
#1e1e1e,正文平均色#ced2ce; - WCAG 对比度 13.73:1,从修复前的约 2.1:1 提升 6 倍多,直接越过 AAA 线(7:1);
- 文字色相分布:灰白正文 75% + 蓝 10.9% + 黄 9.4% + 红 1.7% + 绿 1.6% + 橙 1.3%------语法高亮区分度回来了。
顺带清了最后一个 console 报错:编辑器渲染引用时会调 window.getCitationCallback,citeproc 后端没移植所以函数不存在。在 bundle 加载前补一个降级桩,返回 undefined,编辑器自动回退显示原始 [@key] 文本------代码里本来就有 ?? rawCitation 兜底,安全。

图 3:深色主题下的长文编辑

图 4:编辑状态与保存指示

图 5:工具栏:新建 / 打开 / 保存 / 另存为
九、成果,以及诚实的边界
最终交付的东西,一句话概括:一个运行着 Zettlr 官方编辑器引擎的鸿蒙 PC 原生应用。
真的部分 :CodeMirror 6 编辑器(Zettlr 官方 markdown-editor 源码,零修改)、Markdown 语法高亮、实时渲染、表格编辑器、快捷键、新建/打开/保存/另存为全链路真实文件读写(磁盘级交叉验证)、深色主题(对比度 13.7:1)、单实例、进程树健康零报错。
没有的部分(受 OHOS 沙箱硬限制):文件树侧栏、多标签、引用管理、拼写检查(原生模块不可用,静默降级为"全部认为拼写正确")、Pandoc 导出、Zettlr 官方 Vue 工具栏------顶部那四个按钮是自建的极简外壳。
体积 :HAP 约 200MB,其中 libelectron.so 占 177MB------这是 Electron 路线的固有代价,没什么可羞耻的,但要如实告知。
写清楚边界不是示弱,是给验收的人省时间。"移植了 Zettlr"和"移植了 Zettlr 的编辑器引擎"是两个工作量、两个验收标准的工程,混着说迟早出问题。
十、写给后来者
回头看,整个项目最有复用价值的不是某段代码,而是几条方法论:
- 崩溃归因前先验证对照组。 官方 demo 也崩不代表"环境不支持"------它可能和你一样用着坏依赖。对照实验只能证明一致性,不能证明正确性。
- 判别运行时版本要找结构性特征 ,别依赖文件大小或日期这种弱信号。
entryability/目录下的 Ability 数量,三十秒就能区分新旧底座。 - "完整移植不可行"要靠依赖清单证明,不是拍脑袋。 把原生模块、外部二进制、后端服务列成表逐项判死,剩下的可行域自然浮现。
- 降级要降得优雅。 拼写检查静默降级、引用渲染显示原文、
getCitationCallback补桩------宁可功能缺失,不要运行时报错刷屏。 - 视觉问题用数字说话。 "看不清"量化成 WCAG 对比度 2.1:1,修完量化成 13.7:1;滤镜方案的好坏用色相分布证明。没有数据的争论都是审美之争。
- 验证走独立信道。 应用说保存成功不算数,
hdc shell cat直接读磁盘才算数。 - 每个坑都值得三十秒的判别法。 崩溃看 Ability 数量、装包失败看错误码段位、preload 报错看文件后缀------把踩过的坑压缩成可执行的判别命令,是给未来自己最好的礼物。
鸿蒙 PC 的生态建设,就是这样一个一个应用、一个一个坑填出来的。Zettlr 只是一格拼图,但把 Electron 这条最重的路线趟通了,后面再遇到 Electron 系应用,心里就有底了。
常见问题 FAQ
Q1:为什么不把完整版 Zettlr 移植过来?
不是不想,是硬不可行。nodehun(拼写检查)是 C++ 原生 addon,只有 glibc/Windows/macOS 预编译产物,OHOS 是 musl libc 且无交叉编译链;Pandoc 是外部可执行二进制,沙箱里跑不了。这些依赖逐项判死后,剩下的可行域就是"编辑器引擎"------所以只移植 markdown-editor。
Q2:安装包为什么有 200MB?
libelectron.so 一个文件就占 177MB,这是 Electron 路线的固有代价:要跑真的 Electron 应用,就得带上整个运行时。应用本身的资源(编辑器 bundle + 65 个 chunk)只有 6MB。
Q3:这套方案能直接套到其他 Electron 应用吗?
双模块 HAP 底座(electron entry + web_engine HAR)是通用的,换 bundleName、图标和 resfile/resources/app/ 下的应用资源即可。但每个应用都要先过一遍依赖判死清单:有原生 Node addon 的部分要么找替代、要么砍掉。
Q4:模拟器上能跑吗?
能。启动崩溃曾在模拟器和真机上逐字节复现,一度误判为"模拟器不支持 Electron 多进程"------实际是旧版 libelectron 与鸿蒙 6.1.0.x 的 NAPI 协议不兼容,换新版底座后两边都正常。
Q5:拼写检查、引用管理以后会补上吗?
短期无解。拼写检查需要 nodehun 出 OHOS/musl 产物或社区提供交叉编译链;引用管理依赖 citeproc 完整后端。目前的策略是优雅降级:拼写静默放行、引用显示原始 [@key] 文本,不报错不崩溃。