Subversion(SVN)是经典的集中式版本控制系统,广泛应用于企业级项目管理。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个轻量级 SVN 图形客户端的完整过程——不依赖原生 svn 命令行,而是通过纯 JavaScript 实现 HTTP/WebDAV 协议与 SVN 服务器通信,完成仓库浏览、文件查看、日志检索、版本对比等核心功能,并详细记录鸿蒙平台特有的稳定性踩坑经验。

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

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_subversion

一、技术架构分析

1.1 为什么用 HTTP 协议而不是 svn:// 协议

SVN 支持两种主流通信协议:

协议端口特点
svn://3690二进制协议,需要原生 SVN 库(libsvn)
HTTP/WebDAV80/443基于 HTTP,Apache mod_dav_svn 模块支持

鸿蒙平台目前没有可用的原生 SVN 库,因此本客户端采用 HTTP/WebDAV 协议方案:通过 Node.js 内置的 http/https 模块直接向 SVN 服务器发送标准 HTTP 请求,无需任何外部依赖。

协议约束:本客户端仅支持 Apache mod_dav_svn 和 VisualSVN Server 等提供 HTTP 接口的仓库,不支持 svn:// 协议。

1.2 整体架构

本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程 的分层架构:

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的 SVN 客户端功能(连接管理、仓库浏览、文件查看、日志检索、版本对比)
  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、网络访问、配置持久化等系统能力

1.3 SVN HTTP 通信模型

┌─────────────┐     HTTP/WebDAV      ┌──────────────────┐
│  SVN Client │  ──────────────────▶ │  SVN Server      │
│  (JavaScript)│                      │  (Apache/VSFS)   │
│              │  PROPFIND (列目录)   │                   │
│              │  GET (获取文件)      │  mod_dav_svn     │
│              │  REPORT (查日志)     │                   │
└─────────────┘  ◀── XML Response ── └──────────────────┘

核心 HTTP 方法及其用途:

HTTP 方法SVN 用途说明
PROPFIND列目录、测试连接获取资源属性(名称、类型)
GET获取文件内容支持通过 !svn/ver/{rev} 获取历史版本
REPORT获取提交日志向 !svn/me 端点发送 log-report 请求

1.4 核心功能清单

功能模块具体能力
连接管理添加/编辑/删除连接、测试连接、配置持久化存储
仓库浏览PROPFIND 列目录、目录树导航、返回上级、路径显示
文件查看CodeMirror 语法高亮、指定版本查看
日志检索REPORT 获取提交历史、显示作者/日期/提交信息
版本对比双版本 Diff 对比、CodeMirror 合并视图

二、环境准备

2.1 开发环境要求

项目版本/信息
操作系统Windows 10/11
核心框架Electron (Node.js + Chromium)
技术栈HTML5/CSS3/Vanilla JavaScript
编辑器CodeMirror 5.65.16
目标设备鸿蒙 PC
目标架构arm64-v8a
开发工具DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── Subversion/                   # SVN 客户端源码(开发目录)
│       ├── main.js                   # Electron 主进程(窗口管理 + HTTP 代理 + 配置持久化)
│       ├── renderer.js               # 渲染进程(UI 交互 + SVN 操作逻辑)
│       ├── index.html                # HTML 页面结构
│       ├── package.json              # 项目配置
│       └── styles/
│           └── svn-client.css        # 深色主题样式
├── web_engine/                       # 鸿蒙 web_engine 模块
│   └── src/main/resources/
│       └── resfile/resources/app/    # 部署目录(构建时打包进 HAP)
└── electron/                         # Electron 原生库
    └── libs/arm64-v8a/
        ├── libelectron.so            # Electron 核心库
        ├── libadapter.so             # 鸿蒙适配层库
        └── libffmpeg.so              # 多媒体库

开发流程:在 electron-apps/Subversion/ 中开发,每次修改后同步到 web_engine/src/main/resources/resfile/resources/app/ 部署目录。

三、核心适配流程

3.1 创建 Electron 主进程

文件:electron-apps/Subversion/main.js

主进程负责窗口创建、SVN HTTP 请求代理、连接配置持久化,是整个应用的核心。以下为真实项目代码:

// SVN Client - 主进程
const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const https = require('https');
const http = require('http');
const { URL } = require('url');
const fs = require('fs');
const path = require('path');

// 防 GPU 白屏:禁用硬件加速,降级为软件渲染
app.disableHardwareAcceleration();

let mainWindow = null;

// 连接配置存储
let connections = [];
const configPath = path.join(app.getPath('userData'), 'svn-connections.json');

// 加载配置
function loadConfig() {
  try {
    if (fs.existsSync(configPath)) {
      connections = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
    }
  } catch (e) {
    connections = [];
  }
}

// 保存配置
function saveConfig() {
  try {
    fs.writeFileSync(configPath, JSON.stringify(connections, null, 2), 'utf-8');
  } catch (e) {
    console.error('保存配置失败:', e);
  }
}

// 创建窗口
function createWindow() {
  try {
    const display = screen.getPrimaryDisplay();
    const { width, height } = display.workAreaSize;

    // 防 XComponent 崩溃:frame: true + transparent: false + resizable: true
    mainWindow = new BrowserWindow({
      width: Math.floor(width * 0.9),
      height: Math.floor(height * 0.85),
      frame: true,
      transparent: false,
      resizable: true,
      webPreferences: {
        nodeIntegration: true,
        contextIsolation: false
      }
    });

    mainWindow.loadFile('index.html');
  } catch (e) {
    console.warn('[SVN] 创建窗口失败:', e.message);
  }
}

app.whenReady().then(() => {
  loadConfig();
  createWindow();
});

app.on('window-all-closed', () => {
  app.quit();
});

鸿蒙平台关键配置:

配置项作用
app.disableHardwareAcceleration()启用禁用 GPU 加速,防止白屏
frame: true系统标题栏避免无边框窗口导致 XComponent 崩溃
transparent: false不透明避免透明窗口触发渲染异常
resizable: true允许调整符合鸿蒙窗口管理规范

3.2 实现 SVN HTTP 请求封装

所有 SVN 操作都通过统一的 svnRequest 函数发送 HTTP 请求,以下为真实项目代码:

// 发送 HTTP/HTTPS 请求
function svnRequest(conn, method, urlPath, headers = {}, body = null) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlPath, conn.serverUrl);
    const client = url.protocol === 'https:' ? https : http;

    const reqHeaders = {
      'User-Agent': 'SVN-Client/1.0',
      ...headers
    };
    // 仅在有用户名时发送认证头
    if (conn.username) {
      const auth = Buffer.from(`${conn.username}:${conn.password || ''}`).toString('base64');
      reqHeaders['Authorization'] = `Basic ${auth}`;
    }

    const options = {
      hostname: url.hostname,
      port: url.port,
      path: url.pathname + url.search,
      method: method,
      headers: reqHeaders,
      rejectUnauthorized: false  // 允许自签名证书
    };

    const req = client.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        resolve({
          statusCode: res.statusCode,
          headers: res.headers,
          body: data
        });
      });
    });

    req.on('error', (err) => reject(err));
    req.setTimeout(30000, () => {
      req.destroy();
      reject(new Error('请求超时(30秒)'));
    });

    if (body) req.write(body);
    req.end();
  });
}

设计要点:

  • 自动根据 URL 协议选择 http 或 https 模块
  • Basic Auth 认证:仅在有用户名时发送 Authorization 头(空认证头会导致 Apache 返回 401)
  • 超时设置为 30 秒,适配网络较慢的场景
  • rejectUnauthorized: false 支持自签名证书的内部仓库

3.3 解析 SVN XML 响应

SVN 服务器的 PROPFIND 响应是 XML 格式,需要灵活解析不同命名空间前缀。以下为真实项目代码:

// 解析 PROPFIND 响应(XML)
function parsePropfind(xml, basePath) {
  const items = [];

  // 灵活的 XML 解析:支持不同命名空间前缀(D:, d:, dav: 或无前缀)
  const responseRegex = /<(\w+:)?response[^>]*>([\s\S]*?)<\/(\w+:)?response>/gi;
  const hrefRegex = /<(\w+:)?href[^>]*>([\s\S]*?)<\/(\w+:)?href>/i;
  const displayNameRegex = /<(\w+:)?displayname[^>]*>([\s\S]*?)<\/(\w+:)?displayname>/i;
  const collectionRegex = /<(\w+:)?collection[^>]*\/?>/i;

  // 规范化 basePath:去掉末尾斜杠,用于计算相对路径
  const normalizedBase = basePath.replace(/\/+$/, '');

  let match;
  while ((match = responseRegex.exec(xml)) !== null) {
    const block = match[2]; // 第二个捕获组是内容
    const hrefMatch = hrefRegex.exec(block);
    if (!hrefMatch) continue;

    // href 内容在第四个捕获组
    let href = decodeURIComponent((hrefMatch[4] || hrefMatch[2] || '').trim());
    const hrefNoSlash = href.replace(/\/+$/, '');

    // 跳过根路径本身
    if (hrefNoSlash === normalizedBase) continue;

    // 提取文件名
    const name = hrefNoSlash.split('/').pop();
    if (!name) continue;

    // 转为相对路径(相对于仓库根目录)
    let relativeHref;
    if (hrefNoSlash.startsWith(normalizedBase + '/')) {
      relativeHref = hrefNoSlash.slice(normalizedBase.length + 1);
    } else {
      relativeHref = name;
    }
    if (href.endsWith('/')) relativeHref += '/';

    const isDir = collectionRegex.test(block);
    const displayMatch = displayNameRegex.exec(block);
    const displayName = displayMatch ? (displayMatch[4] || displayMatch[2] || '').trim() : name;

    items.push({
      name: displayName || name,
      href: relativeHref,
      isDirectory: isDir
    });
  }

  // 排序:文件夹在前,按名称排序
  items.sort((a, b) => {
    if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
    return a.name.localeCompare(b.name);
  });

  return items;
}

踩坑记录:不同 SVN 服务器使用不同的 XML 命名空间前缀。Apache 使用 D:,某些服务器使用 d: 或小写 dav:,甚至可能没有前缀。使用 (\w+:)? 正则模式可以灵活匹配所有情况。

3.4 实现 IPC 通信层

主进程通过 ipcMain.handle 注册 IPC 通道,渲染进程通过 ipcRenderer.invoke 调用。以下为真实项目代码中的四个核心 SVN 通道:

// ========== IPC 通道 ==========

// 测试连接
ipcMain.handle('svn:test-connection', async (event, conn) => {
  try {
    // 使用 PROPFIND 请求测试(SVN 标准方式)
    const url = new URL(conn.serverUrl);
    const body = `<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
  <D:prop><D:resourcetype/></D:prop>
</D:propfind>`;

    const res = await svnRequest(conn, 'PROPFIND', url.pathname || '/', {
      'Depth': '0',
      'Content-Type': 'application/xml'
    }, body);

    if (res.statusCode === 207 || res.statusCode === 200) {
      return {
        success: true,
        isSvnServer: true,
        statusCode: res.statusCode,
        message: '连接成功'
      };
    }
    if (res.statusCode === 401) {
      return {
        success: true,
        isSvnServer: true,
        statusCode: 401,
        message: '服务器可达,需要认证(请填写用户名密码)'
      };
    }
    return { success: false, message: `服务器响应: ${res.statusCode}` };
  } catch (err) {
    return { success: false, message: err.message };
  }
});

// 列出目录内容
ipcMain.handle('svn:list-dir', async (event, conn, repoPath) => {
  try {
    // 规范化 URL 拼接:确保 serverUrl 以 / 结尾,repoPath 不以 / 开头
    const base = conn.serverUrl.endsWith('/') ? conn.serverUrl : conn.serverUrl + '/';
    const relPath = repoPath.replace(/^\/+/, '');
    const url = new URL(relPath, base);

    const body = `<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:S="http://subversion.tigris.org/xmlns/dav/">
  <D:prop>
    <D:displayname/>
    <D:resourcetype/>
    <S:baseline-relative-path/>
  </D:prop>
</D:propfind>`;

    const res = await svnRequest(conn, 'PROPFIND', url.pathname, {
      'Depth': '1',
      'Content-Type': 'application/xml'
    }, body);

    if (res.statusCode === 207 || res.statusCode === 200) {
      const items = parsePropfind(res.body, url.pathname);
      return { success: true, items };
    }
    return { success: false, message: `请求失败: ${res.statusCode}` };
  } catch (err) {
    return { success: false, message: err.message };
  }
});

// 获取文件内容
ipcMain.handle('svn:get-file', async (event, conn, filePath, revision) => {
  try {
    const base = conn.serverUrl.endsWith('/') ? conn.serverUrl : conn.serverUrl + '/';
    const relPath = filePath.replace(/^\/+/, '');
    const url = new URL(relPath, base);
    let urlPath = url.pathname;

    if (revision) {
      // 通过 !svn/ver/{rev} 获取指定版本(在路径中插入版本号)
      const parts = urlPath.split('/').filter(Boolean);
      urlPath = '/' + parts.slice(0, -1).join('/') + '/!svn/ver/' + revision + '/' + parts[parts.length - 1];
    }

    const res = await svnRequest(conn, 'GET', urlPath, {}, null);
    if (res.statusCode === 200) {
      return { success: true, content: res.body };
    }
    return { success: false, message: `获取失败: ${res.statusCode}` };
  } catch (err) {
    return { success: false, message: err.message };
  }
});

// 获取提交日志
ipcMain.handle('svn:get-log', async (event, conn, repoPath, limit) => {
  try {
    const base = conn.serverUrl.endsWith('/') ? conn.serverUrl : conn.serverUrl + '/';
    const relPath = repoPath.replace(/^\/+/, '');
    const url = new URL(relPath, base);
    const body = `<?xml version="1.0" encoding="utf-8"?>
<S:log-report xmlns:S="http://subversion.tigris.org/xmlns/dav/" xmlns:D="DAV:" xmlns:dc="http://purl.org/dc/elements/1.0/">
  <S:start-revision>1</S:start-revision>
  <S:end-revision>HEAD</S:end-revision>
  <S:limit>${limit || 50}</S:limit>
  <S:want-inherited-props/>
  <S:include-merged-revisions/>
  <D:prop>
    <dc:creator/>
    <dc:date/>
    <S:message/>
  </D:prop>
</S:log-report>`;

    const logPath = url.pathname + '/!svn/me';
    const res = await svnRequest(conn, 'REPORT', logPath, {
      'Content-Type': 'application/xml'
    }, body);

    if (res.statusCode === 200) {
      const logs = parseLogReport(res.body);
      return { success: true, logs };
    }
    return { success: false, message: `获取日志失败: ${res.statusCode}` };
  } catch (err) {
    return { success: false, message: err.message };
  }
});

IPC 通道设计:

通道名功能HTTP 方法
svn:test-connection测试 SVN 连接PROPFIND (Depth: 0)
svn:list-dir列出仓库目录PROPFIND (Depth: 1)
svn:get-file获取文件内容GET
svn:get-log获取提交日志REPORT
config:get-connections获取连接配置本地文件读取
config:save-connection保存连接配置本地文件写入
config:delete-connection删除连接配置本地文件写入

3.5 构建页面布局

文件:electron-apps/Subversion/index.html

页面采用经典的三区域布局:工具栏 + 侧边栏(仓库树) + 内容面板(文件/日志/Diff),底部状态栏显示连接信息。核心 HTML 结构如下:

<body>
  <!-- 顶部工具栏 -->
  <div class="toolbar">
    <div class="toolbar-left">
      <span class="logo">SVN Client</span>
      <!-- 自定义下拉框(替代原生 select,防止鸿蒙 SubWindow 崩溃) -->
      <div class="custom-dropdown" id="conn-select">
        <div class="dropdown-selected">-- 选择连接 --</div>
        <div class="dropdown-options"></div>
      </div>
      <button id="btn-manage-conn" title="管理连接">⚙️</button>
      <div class="toolbar-separator"></div>
      <button id="btn-refresh" title="刷新">🔄</button>
      <button id="btn-back" title="返回上级" disabled>⬆️</button>
    </div>
    <div class="toolbar-right">
      <button id="btn-view-tree" class="view-btn active" title="仓库浏览">📂</button>
      <button id="btn-view-log" class="view-btn" title="提交日志">📋</button>
      <button id="btn-view-diff" class="view-btn" title="版本对比">🔀</button>
    </div>
  </div>

  <!-- 主内容区域 -->
  <div class="main">
    <!-- 左侧:仓库目录树 -->
    <div class="sidebar" id="sidebar">
      <div class="sidebar-header">
        <span class="sidebar-title">仓库浏览</span>
        <span class="sidebar-path" id="current-path">/</span>
      </div>
      <div class="sidebar-content" id="repo-tree">
        <div class="sidebar-empty">
          <p>请先配置 SVN 连接</p>
          <button id="btn-add-conn">添加连接</button>
        </div>
      </div>
    </div>

    <!-- 侧边栏拖动条 -->
    <div class="sidebar-resizer" id="sidebarResizer"></div>

    <!-- 右侧:内容区域 -->
    <div class="content-panel" id="contentPanel">
      <!-- 文件查看视图 -->
      <div class="view" id="view-file">...</div>
      <!-- 日志查看视图 -->
      <div class="view" id="view-log" style="display:none;">...</div>
      <!-- Diff 对比视图 -->
      <div class="view" id="view-diff" style="display:none;">...</div>
    </div>
  </div>

  <!-- 底部状态栏 -->
  <div class="statusbar">
    <span id="status-conn">未连接</span>
    <span id="status-path">/</span>
    <span id="status-info"></span>
  </div>

  <!-- 连接管理弹窗 -->
  <div class="modal" id="conn-modal" style="display:none;">...</div>
</body>

布局设计说明:

  • 整体使用 flex 布局,body 为 flex-direction: column(垂直排列工具栏/主内容/状态栏)
  • .main 区域为 flex-direction: row(水平排列侧边栏/内容面板)
  • 侧边栏支持拖动调整宽度(最小 120px,最大 500px)
  • 三个视图(文件/日志/Diff)通过 display: none/block 切换

3.6 实现连接管理

连接管理支持添加、编辑、删除、测试连接,配置持久化到本地 JSON 文件。以下为真实项目代码:

// ========== 连接配置管理 ==========

ipcMain.handle('config:get-connections', async () => {
  return { success: true, connections };
});

ipcMain.handle('config:save-connection', async (event, conn) => {
  const existing = connections.findIndex(c => c.id === conn.id);
  if (existing >= 0) {
    connections[existing] = conn;
  } else {
    conn.id = Date.now().toString();
    connections.push(conn);
  }
  saveConfig();
  return { success: true, connections };
});

ipcMain.handle('config:delete-connection', async (event, id) => {
  connections = connections.filter(c => c.id !== id);
  saveConfig();
  return { success: true, connections };
});

配置文件存储路径:{userData}/svn-connections.json

四、鸿蒙平台稳定性适配(重点)

这是本次适配中最具挑战性的部分。鸿蒙 Electron 适配层 libadapter.so 存在一些平台级限制,需要针对性地绕过。

4.1 问题一:原生 select 下拉框导致崩溃

问题现象:点击连接选择下拉框,第一次正常,第二次点击直接闪退

崩溃堆栈:

Signal:SIGABRT from CrBrowserMain
#04 XComponentManager::WaitForXComponentCreated
#05 XComponentManager::CreateSubWindow
#06 XComponentManager::CreateWindow

根本原因:原生 select 元素在鸿蒙 Electron 适配层会创建原生弹出窗口(SubWindow),第二次创建时 XComponentManager::WaitForXComponentCreated 超时触发 SIGABRT

解决方案:用自定义 div 下拉框替换所有原生 select,以下为真实项目代码:

HTML 部分:

<!-- 替换前 -->
<select id="conn-select" class="conn-select">
  <option value="">-- 选择连接 --</option>
</select>

<!-- 替换后 -->
<div class="custom-dropdown" id="conn-select">
  <div class="dropdown-selected">-- 选择连接 --</div>
  <div class="dropdown-options"></div>
</div>

JavaScript 部分(renderer.js 真实代码):

// 自定义下拉框初始化(替代原生 select,防止鸿蒙 SubWindow 崩溃)
function initCustomDropdown(id, onChange) {
  const dropdown = document.getElementById(id);
  if (!dropdown) return;
  const selected = dropdown.querySelector('.dropdown-selected');
  const options = dropdown.querySelector('.dropdown-options');

  // 点击显示/隐藏选项
  selected.addEventListener('click', (e) => {
    e.stopPropagation();
    // 关闭其他下拉框
    document.querySelectorAll('.custom-dropdown.open').forEach(d => {
      if (d !== dropdown) d.classList.remove('open');
    });
    dropdown.classList.toggle('open');
  });

  // 点击选项
  options.querySelectorAll('.dropdown-option').forEach(opt => {
    opt.addEventListener('click', (e) => {
      e.stopPropagation();
      const value = opt.dataset.value;
      const text = opt.textContent;
      // 更新显示
      selected.textContent = text;
      selected.dataset.value = value;
      // 更新 active 状态
      options.querySelectorAll('.dropdown-option').forEach(o => o.classList.remove('active'));
      opt.classList.add('active');
      // 关闭下拉框
      dropdown.classList.remove('open');
      // 触发回调
      if (onChange) onChange(value);
    });
  });

  // 监听动态更新选项
  dropdown.addEventListener('dropdown-change', (e) => {
    const value = e.detail.value;
    const opt = options.querySelector(`[data-value="${value}"]`);
    if (opt) {
      selected.textContent = opt.textContent;
      selected.dataset.value = value;
      options.querySelectorAll('.dropdown-option').forEach(o => o.classList.remove('active'));
      opt.classList.add('active');
    }
    dropdown.classList.remove('open');
    if (onChange) onChange(value);
  });
}

// 点击其他区域关闭下拉框
document.addEventListener('click', () => {
  document.querySelectorAll('.custom-dropdown.open').forEach(d => d.classList.remove('open'));
});

4.2 问题二:原生 confirm() 对话框同样触发崩溃

问题现象:删除连接时弹出确认对话框,触发同样的 SubWindow 崩溃

解决方案:用自定义 div 确认对话框替代原生 confirm(),以下为真实项目代码:

// 自定义确认对话框(替代原生 confirm)
function showConfirm(message) {
  return new Promise((resolve) => {
    const overlay = document.createElement('div');
    overlay.className = 'confirm-overlay';
    overlay.innerHTML = `
      <div class="confirm-dialog">
        <p class="confirm-message">${message}</p>
        <div class="confirm-actions">
          <button class="confirm-cancel">取消</button>
          <button class="confirm-ok">确定</button>
        </div>
      </div>
    `;
    document.body.appendChild(overlay);

    overlay.querySelector('.confirm-ok').addEventListener('click', () => {
      document.body.removeChild(overlay);
      resolve(true);
    });
    overlay.querySelector('.confirm-cancel').addEventListener('click', () => {
      document.body.removeChild(overlay);
      resolve(false);
    });
  });
}

// 使用方式
window.deleteConnection = async function(id) {
  if (!await showConfirm('确定删除此连接?')) return;
  const result = await ipcRenderer.invoke('config:delete-connection', id);
  if (result.success) {
    connections = result.connections;
    updateConnSelect();
    updateConnList();
  }
};

4.3 问题三:setWindowOpenHandler 导致页面纯白

问题现象:添加 setWindowOpenHandler 和 will-navigate 拦截器后,页面完全不渲染,显示纯白

根本原因:鸿蒙 Electron 适配层 libadapter.so 不支持这两个 API,调用后触发渲染引擎异常

解决方案:移除所有拦截器,依赖上述自定义组件从源头避免触发原生弹窗

// ❌ 不要使用(鸿蒙平台不兼容)
mainWindow.setWindowOpenHandler(() => ({ action: 'deny' }));
mainWindow.webContents.on('will-navigate', (event, url) => { ... });

// ✅ 正确做法:从源头避免触发 SubWindow
// 1. 用自定义 div 替代所有 select
// 2. 用自定义 div 替代 confirm()/alert()/prompt()

4.4 "三防"稳定性策略总结

防护目标措施代码位置
防 XComponent 崩溃frame: true + transparent: false + resizable: truemain.js BrowserWindow 配置
防 GPU 白屏app.disableHardwareAcceleration()main.js 启动时调用
防单点故障所有关键操作 try-catch 包裹createWindow、文件读写、IPC 处理等
防原生弹窗崩溃自定义 div 替代 select、confirm()renderer.js UI 组件

五、文件同步部署

每次修改 electron-apps/Subversion/ 下的代码后,需要同步到鸿蒙 web_engine 部署目录:

# 清空部署目录
Remove-Item "web_engine\src\main\resources\resfile\resources\app\*" -Recurse -Force

# 复制最新文件
Copy-Item "electron-apps\Subversion\*" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\" `
  -Recurse -Force

注意:每次修改代码后都必须同步,否则构建的 HAP 包不会包含最新代码。

六、可测试的公开 SVN 仓库

名称服务器地址说明
Apache (subversion)https://svn.apache.org/repos/asf/subversion/SVN 自身源码
Apache (httpd)https://svn.apache.org/repos/asf/httpd/Apache HTTP Server
Apache (maven)https://svn.apache.org/repos/asf/maven/Maven 构建工具
Apache (tomcat)https://svn.apache.org/repos/asf/tomcat/Tomcat 服务器
Python (cpython)https://svn.python.org/projects/python/CPython 源码

这些都是只读公开仓库,无需认证,可以直接测试目录浏览、文件查看、日志加载等功能。

七、常见问题与解决方案

Q1:连接测试返回"需要认证"但没有填用户名

问题现象:Apache 公开仓库(如 svn.apache.org)返回 401

根本原因:即使不填用户名密码,代码也发送了空的 Basic Auth 头(Basic Og==,即 username:password 为空字符串的 Base64),触发服务器要求认证

解决方案:仅在有用户名时发送 Authorization 头

// 仅在有用户名时发送认证头
if (conn.username) {
  const auth = Buffer.from(`${conn.username}:${conn.password || ''}`).toString('base64');
  reqHeaders['Authorization'] = `Basic ${auth}`;
}

Q2:仓库浏览显示空目录

问题现象:连接成功但目录列表为空

根本原因:PROPFIND 返回的 href 是服务器绝对路径(如 /repos/asf/subversion/),使用 new URL() 处理时会替换掉基础路径,导致子项路径计算错误

解决方案:

  1. URL 拼接规范化:确保 serverUrl 以 / 结尾,repoPath 不以 / 开头
  2. 将 href 转为相对路径:从绝对路径中截取相对于仓库根目录的部分
// 规范化 URL 拼接
const base = conn.serverUrl.endsWith('/') ? conn.serverUrl : conn.serverUrl + '/';
const relPath = repoPath.replace(/^\/+/, '');
const url = new URL(relPath, base);

// 解析时将绝对 href 转为相对路径
if (hrefNoSlash.startsWith(normalizedBase + '/')) {
  relativeHref = hrefNoSlash.slice(normalizedBase.length + 1);
}

Q3:XML 解析失败(某些 SVN 服务器)

问题现象:部分服务器返回的 XML 解析结果为空

根本原因:不同 SVN 服务器使用不同的 XML 命名空间前缀(D:、d:、dav: 或无前缀),正则表达式写死了特定前缀

解决方案:使用 (\w+:)? 灵活匹配任意命名空间前缀

// 支持 <D:response>、<d:response>、<response> 等所有形式
const responseRegex = /<(\w+:)?response[^>]*>([\s\S]*?)<\/(\w+:)?response>/gi;

Q4:MySQL SVN 仓库连接超时

问题现象:svn.mysql.com 连接超时

解决方案:将请求超时时间从 10 秒增加到 30 秒

req.setTimeout(30000, () => {
  req.destroy();
  reject(new Error('请求超时(30秒)'));
});

八、总结

本文完整记录了 Subversion 客户端在鸿蒙 PC 平台的适配过程。核心技术要点总结如下:

技术点方案
SVN 通信协议HTTP/WebDAV(PROPFIND + GET + REPORT)
XML 解析正则表达式 + 灵活命名空间前缀匹配
认证方式Basic Auth(仅在有用户名时发送)
代码查看CodeMirror 5 语法高亮
配置持久化JSON 文件存储于 userData 目录
鸿蒙稳定性三防策略 + 自定义 div 替代原生弹窗
布局方案Flex 布局 + 可拖动侧边栏
主题配色深色主题(VS Code 风格)

核心经验:鸿蒙 Electron 适配层目前对原生弹窗(select 下拉、confirm() 对话框等)的支持存在限制,解决方案是从源头避免触发原生 SubWindow——用纯 HTML/CSS/JS 实现的自定义组件替代所有原生弹窗元素。这套"自定义组件替代"策略在真机上验证有效,确保了应用的稳定运行。

整个适配过程遵循 Electron 壳方案标准化流程:在 electron-apps/ 开发目录中编写代码,通过同步脚本部署到 web_engine/ 模块,最终由鸿蒙壳工程打包为 HAP 安装包。开发者可以专注于 Web 技术栈和 SVN 协议实现,无需关心平台差异。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐