📋 项目概述

本文档详细介绍了如何将 MarkRight(一个极简主义的 GitHub Flavored Markdown 编辑器)成功适配到 HarmonyOS PC 平台。基于 Electron for HarmonyOS 框架,通过深度适配和优化,实现了在鸿蒙 PC 上的完美运行。

欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/

📸 效果展示

以下是 MarkRight 在 HarmonyOS PC 平台上的运行效果:

演示示例 1

在这里插入图片描述

MarkRight 应用主界面在 HarmonyOS PC 上的显示效果

演示示例 2

在这里插入图片描述

代码高亮、实时预览和 Markdown 渲染效果

从效果图可以看出,MarkRight 应用已成功适配到 HarmonyOS PC 平台,界面显示正常,功能完整可用。

项目背景

MarkRight 是一个使用 ClojureScript 编写的 Electron 应用,具有以下特点:

  • 使用 ClojureScript 编译生成 JavaScript
  • 自定义 IPC 通信系统(使用 ClojureScript 的 pr-strread-string
  • 使用旧版 Electron API(0.33.6)
  • 依赖多个 Node.js native 模块

适配挑战

  1. 自定义 IPC 通信系统:MarkRight 使用 ClojureScript 格式的消息序列化
  2. 图片路径处理:需要正确处理相对路径、绝对路径和 HTTP 图片
  3. Shell 模块访问:渲染进程中需要访问 shell.openExternal()
  4. BrowserWindow 配置:需要确保所有窗口都正确注入 preload 脚本

🎯 核心功能实现

1. 自定义 IPC 通信系统

问题分析

MarkRight 使用 ClojureScript 的 pr-strread-string 进行消息序列化,通过 electron.ipc 通道进行通信。消息格式如下:

{:electron.ipc/op :cast, :electron.ipc/action :load-file}
{:electron.ipc/op :call, :electron.ipc/action :get-current-content}
解决方案

1.1 主进程 IPC 处理器

main.js 中添加 electron.ipc 通道监听器:

ipcMain.on('electron.ipc', (event, message) => {
    console.log('[HarmonyOS IPC] Received MarkRight IPC message');
  
    try {
        let electronIPC = null;
        // 从全局作用域获取 electron.ipc(由 app.js.original 导出)
        if (typeof global.electron !== 'undefined' && global.electron && global.electron.ipc) {
            electronIPC = global.electron.ipc;
        }
  
        if (electronIPC && typeof electronIPC.process_msg === 'function') {
            // 创建 IPC 回复对象
            const ipcForReply = {
                send: function(channel, msg) {
                    if (event.sender && !event.sender.isDestroyed()) {
                        event.sender.send(channel, msg);
                    }
                }
            };
      
            // 设置 IPC 目标
            if (electronIPC._STAR_ipc_target_STAR_) {
                electronIPC.cljs$core$vreset_BANG_(
                    electronIPC._STAR_ipc_target_STAR_, 
                    ipcForReply
                );
            }
      
            // 处理消息
            electronIPC.process_msg(event, message);
        } else {
            // 如果 electron.ipc 尚未加载,将消息加入队列
            if (!global.__markrightIPCMessageQueue) {
                global.__markrightIPCMessageQueue = [];
            }
            global.__markrightIPCMessageQueue.push({ event, message });
        }
    } catch (error) {
        console.error('[HarmonyOS IPC] Error processing message:', error);
    }
});

1.2 导出 electron 对象

app.js.original 末尾添加:

// 导出 electron 对象到全局作用域,供 IPC 处理器使用
global.electron = electron;

1.3 处理队列消息

在 MarkRight 加载完成后处理队列中的消息:

// 处理队列中的 IPC 消息
if (global.__markrightIPCMessageQueue && global.__markrightIPCMessageQueue.length > 0) {
    const queue = [...global.__markrightIPCMessageQueue];
    global.__markrightIPCMessageQueue = [];
    queue.forEach(({ event, message }) => {
        ipcMain.emit('electron.ipc', event, message);
    });
}

1.4 设置窗口 IPC 目标

在创建 BrowserWindow 时设置 IPC 目标:

// 在 HarmonyOSBrowserWindow 构造函数中
if (global.__createMarkRightIPCWrapper) {
    const ipcWrapper = global.__createMarkRightIPCWrapper(win);
    if (global.electron && global.electron.ipc) {
        global.electron.ipc.cljs$core$vreset_BANG_(
            global.electron.ipc._STAR_ipc_target_STAR_, 
            ipcWrapper
        );
    }
}

2. 图片路径处理

问题分析

MarkRight 的 parse-images! 函数会将所有非 HTTP 图片路径转换为 file:// URL,但对于绝对路径图片,会错误地拼接 current-path,导致路径错误。

例如:

  • 绝对路径:/Users/name/pic.png
  • 错误结果:file:///path/to/md/file//Users/name/pic.png
解决方案

2.1 创建图片路径修复补丁

创建 ui/js/image-path-fix.js

(function() {
    'use strict';
  
    // 检测绝对路径
    function isAbsolutePath(path) {
        if (!path || typeof path !== 'string') return false;
        // Unix/Linux/macOS 绝对路径
        if (path.startsWith('/')) return true;
        // Windows 绝对路径
        if (/^[A-Za-z]:[\\/]/.test(path)) return true;
        return false;
    }
  
    // 修复图片路径
    function fixImagePaths() {
        const imgTags = document.getElementsByTagName('img');
        Array.from(imgTags).forEach(function(imgTag) {
            const src = imgTag.getAttribute('src');
            const dataSrc = imgTag.getAttribute('data-src');
      
            // 跳过 HTTP 图片
            if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
                return;
            }
      
            // 如果 data-src 是绝对路径,直接使用
            if (dataSrc && isAbsolutePath(dataSrc)) {
                const fixedUrl = 'file://' + dataSrc.replace(/\\/g, '/');
                imgTag.setAttribute('src', fixedUrl);
            }
        });
    }
  
    // 拦截 parse_images_BANG_ 函数
    function patchImageParsing() {
        if (typeof markright === 'undefined' || 
            typeof markright.components === 'undefined' ||
            typeof markright.components.markdown === 'undefined' ||
            typeof markright.components.markdown.parse_images_BANG_ === 'undefined') {
            setTimeout(patchImageParsing, 100);
            return;
        }
  
        const originalParseImages = markright.components.markdown.parse_images_BANG_;
  
        // 包装原函数
        markright.components.markdown.parse_images_BANG_ = function(currentPath) {
            originalParseImages.call(this, currentPath);
            setTimeout(fixImagePaths, 10);
        };
  
        // 修复现有图片
        fixImagePaths();
    }
  
    // 使用 MutationObserver 监控新添加的图片
    const observer = new MutationObserver(function(mutations) {
        let hasNewImages = false;
        mutations.forEach(function(mutation) {
            if (mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(function(node) {
                    if (node.nodeType === 1 && 
                        (node.tagName === 'IMG' || node.querySelectorAll('img').length > 0)) {
                        hasNewImages = true;
                    }
                });
            }
        });
        if (hasNewImages) {
            setTimeout(fixImagePaths, 100);
        }
    });
  
    // 启动监控和补丁
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function() {
            setTimeout(patchImageParsing, 500);
            if (document.body) {
                observer.observe(document.body, {
                    childList: true,
                    subtree: true
                });
            }
        });
    } else {
        setTimeout(patchImageParsing, 500);
        if (document.body) {
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        }
    }
})();

2.2 在 index.html 中加载补丁

<script>
const script = document.createElement('script');
script.src = 'js/front.js';
script.onload = function() {
    // 加载图片路径修复补丁
    const patchScript = document.createElement('script');
    patchScript.src = 'js/image-path-fix.js';
    document.body.appendChild(patchScript);
};
document.body.appendChild(script);
</script>

3. Shell 模块 Polyfill

问题分析

MarkRight 在链接的 onclick 处理器中使用 require('shell').openExternal(...),但在渲染进程中 shell 模块不可用。

解决方案

3.1 在 preload.js 中添加 shell polyfill

const { ipcRenderer } = require('electron');

// 创建 shell 模块 polyfill
const shell = {
    openExternal: function(url) {
        console.log('[HarmonyOS Preload] shell.openExternal() called:', url);
        // 通过 IPC 调用主进程
        ipcRenderer.send('electron-shell-openExternal', url);
    }
};

// 拦截 require('shell')
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function(id) {
    if (id === 'shell') {
        return shell;
    }
    return originalRequire.apply(this, arguments);
};

// 提供 window.require 供 onclick 处理器使用
if (typeof window !== 'undefined') {
    window.shell = shell;
    window.require = function(id) {
        if (id === 'shell') return shell;
        if (id === 'ipc') return ipc;
        if (typeof require !== 'undefined') {
            return require(id);
        }
        throw new Error(`Cannot find module '${id}'`);
    };
}

3.2 在主进程中添加 IPC 处理器

// 在 main.js 中
ipcMain.on('electron-shell-openExternal', (event, url) => {
    try {
        console.log('[HarmonyOS IPC] shell.openExternal called:', url);
        const { shell } = require('electron');
        shell.openExternal(url);
    } catch (error) {
        console.error('[HarmonyOS IPC] Error in shell.openExternal:', error);
    }
});

4. BrowserWindow 拦截

问题分析

需要确保所有 BrowserWindow 实例都正确注入 preload.js 并设置必要的 webPreferences

解决方案

4.1 创建 HarmonyOSBrowserWindow 包装类

// 在 main.js 中
class HarmonyOSBrowserWindow {
    constructor(options) {
        const opts = options || {};
  
        // 确保 webPreferences 存在
        if (!opts.webPreferences) {
            opts.webPreferences = {};
        }
  
        // 设置必要的配置
        opts.webPreferences.nodeIntegration = true;
        opts.webPreferences.contextIsolation = false;
        opts.webPreferences.webSecurity = false;
  
        // 注入 preload 脚本
        const preloadPath = path.join(app.getAppPath(), 'preload.js');
        opts.webPreferences.preload = preloadPath;
  
        // 创建原始 BrowserWindow
        const win = new BrowserWindow(opts);
  
        // 设置 MarkRight IPC 目标
        if (global.__createMarkRightIPCWrapper) {
            const ipcWrapper = global.__createMarkRightIPCWrapper(win);
            if (global.electron && global.electron.ipc) {
                global.electron.ipc.cljs$core$vreset_BANG_(
                    global.electron.ipc._STAR_ipc_target_STAR_, 
                    ipcWrapper
                );
            }
        }
  
        return win;
    }
}

// 导出到全局作用域
global.HarmonyOSBrowserWindow = HarmonyOSBrowserWindow;

4.2 在 electron-polyfill.js 中使用包装类

let BrowserWindow = require('electron').BrowserWindow;
if (global.HarmonyOSBrowserWindow) {
    BrowserWindow = global.HarmonyOSBrowserWindow;
}

const BrowserWindowPolyfill = function(options) {
    const opts = options || {};
    if (!opts.webPreferences) {
        opts.webPreferences = {};
    }
    opts.webPreferences.nodeIntegration = true;
    opts.webPreferences.contextIsolation = false;
    opts.webPreferences.webSecurity = false;
  
    // 使用 HarmonyOSBrowserWindow(如果可用)
    const win = new BrowserWindow(opts);
  
    // 添加兼容方法
    if (!win.loadUrl) {
        win.loadUrl = function(url) {
            return this.loadURL(url);
        };
    }
  
    return win;
};

📊 功能测试结果

所有功能已通过完整测试:

测试项目 状态 说明
文件打开 菜单打开、拖放打开
文件保存 保存、另存为
未保存提示 关闭窗口时提示
编辑预览 实时预览更新
相对路径图片 正常显示
绝对路径图片 已修复
HTTP 图片 网络图片加载
Markdown 链接 外部浏览器打开
Help 菜单链接 GitCode 链接正常
文件拖放 单个/多个文件

测试进度:12/12 已完成 (100%)


🔍 关键技术细节

1. ClojureScript IPC 消息格式

MarkRight 使用 ClojureScript 的 pr-str 进行消息序列化,消息格式如下:

;; Cast 消息(不需要回复)
{:electron.ipc/op :cast, :electron.ipc/action :load-file, :file "path/to/file.md", :content "..."}

;; Call 消息(需要回复)
{:electron.ipc/op :call, :electron.ipc/action :get-current-content, :electron.ipc/ref #uuid "..."}

2. IPC 目标设置

MarkRight 使用 electron.ipc.set_target_BANG_() 设置 IPC 目标窗口。在 HarmonyOS 适配中,我们通过以下方式实现:

// 创建 IPC 包装器
global.__createMarkRightIPCWrapper = function(win) {
    return {
        send: function(channel, msg) {
            if (win.webContents && !win.webContents.isDestroyed()) {
                win.webContents.send(channel, msg);
            }
        }
    };
};

3. 消息队列机制

由于 MarkRight 的 electron.ipc 可能在 IPC 消息到达时尚未加载,我们实现了消息队列机制:

// 如果 electron.ipc 未加载,将消息加入队列
if (!electronIPC || typeof electronIPC.process_msg !== 'function') {
    if (!global.__markrightIPCMessageQueue) {
        global.__markrightIPCMessageQueue = [];
    }
    global.__markrightIPCMessageQueue.push({ event, message });
}

// MarkRight 加载后处理队列
if (global.__markrightIPCMessageQueue && global.__markrightIPCMessageQueue.length > 0) {
    const queue = [...global.__markrightIPCMessageQueue];
    global.__markrightIPCMessageQueue = [];
    queue.forEach(({ event, message }) => {
        ipcMain.emit('electron.ipc', event, message);
    });
}

🐛 常见问题与解决方案

问题 1:图片不显示

症状: 绝对路径图片不显示

原因: MarkRight 的图片处理逻辑错误拼接了绝对路径

解决方案: 使用 image-path-fix.js 补丁脚本检测并修复绝对路径

问题 2:链接点击无反应

症状: 点击 Markdown 链接无反应,控制台报错 Cannot find module 'shell'

原因: 渲染进程中 shell 模块不可用

解决方案:preload.js 中添加 shell 模块 polyfill,通过 IPC 调用主进程

问题 3:IPC 消息未处理

症状: IPC 消息发送后无响应

原因: electron.ipc 对象尚未加载

解决方案: 实现消息队列机制,在 MarkRight 加载后处理队列中的消息


📈 性能优化

1. 延迟加载

  • MarkRight 的主进程代码在 app.whenReady() 之前加载,确保事件监听器正确注册
  • 图片路径修复补丁在 front.js 加载后加载,避免时序问题

2. 消息队列

  • 使用消息队列避免消息丢失
  • 在 MarkRight 加载完成后批量处理队列消息

3. MutationObserver

  • 使用 MutationObserver 实时监控 DOM 变化
  • 只在有新图片添加时才执行修复逻辑

🎓 经验总结

1. 理解应用架构

在适配 Electron 应用到 HarmonyOS 之前,需要深入理解应用的架构:

  • 主进程和渲染进程的通信方式
  • IPC 消息格式和序列化方式
  • 依赖的 Node.js 模块和 native 模块

2. 渐进式适配

采用渐进式适配策略:

  1. 先实现基本的 IPC 通信
  2. 逐步添加功能支持
  3. 修复发现的问题
  4. 优化性能和体验

3. 补丁机制

对于无法直接修改的编译后代码,使用补丁机制:

  • 在运行时拦截和修改函数
  • 使用 MutationObserver 监控 DOM 变化
  • 提供 polyfill 替代不可用的模块

4. 完整测试

确保所有功能都经过完整测试:

  • 文件操作(打开、保存、另存为)
  • 图片显示(相对路径、绝对路径、HTTP)
  • 链接功能(Markdown 链接、菜单链接)
  • 文件拖放

🔗 相关资源


📝 更新日志

v1.0.0 (2024-11-28)

  • ✅ 完成 MarkRight 到 HarmonyOS PC 的完整适配
  • ✅ 实现自定义 IPC 通信系统
  • ✅ 修复绝对路径图片显示问题
  • ✅ 实现 Shell 模块 polyfill
  • ✅ 更新 Help 菜单链接为 GitCode
  • ✅ 所有功能测试通过

Made with ❤️ for HarmonyOS PC

Logo

赋能鸿蒙PC开发者,共建全场景原生生态,共享一次开发多端部署创新价值。

更多推荐