鸿蒙平台 Subversion 客户端适配实战:基于 HTTP_WebDAV 协议的轻量级 SVN 图形界面
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/WebDAV | 80/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: true | main.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() 处理时会替换掉基础路径,导致子项路径计算错误
解决方案:
- URL 拼接规范化:确保 serverUrl 以 / 结尾,repoPath 不以 / 开头
- 将 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 协议实现,无需关心平台差异。
更多推荐



所有评论(0)