ITPub博客

首页 > 应用开发 > Javascript > 技术解读:鸿蒙平台 Subversion 客户端适配实战:基于 HTTP_WebDAV 协议的轻量级 SVN 图形界面_鸿蒙系统

技术解读:鸿蒙平台 Subversion 客户端适配实战:基于 HTTP_WebDAV 协议的轻量级 SVN 图形界面_鸿蒙系统

Javascript 作者:Flow_Official 时间:2026-10-10 18:24:42 0 删除 编辑
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:// | 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 方法 | 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/
│       ├── main.js
│       ├── renderer.js
│       ├── index.html
│       ├── package.json
│       └── styles/
│           └── svn-client.css
├── web_engine/
│   └── src/main/resources/
│       └── resfile/resources/app/
└── electron/
└── libs/arm64-v8a/
        ├── libelectron.so
├── 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 请求代理、连接配置持久化,是整个应用的核心。以下为真实项目代码:

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'
)
;
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
;
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 请求,以下为真实项目代码:

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 格式,需要灵活解析不同命名空间前缀。以下为真实项目代码:

function
parsePropfind
(
xml
,
basePath
)
{
const
items
=
[
]
;
const
responseRegex
=
/
]*>([\s\S]*?)
/
gi
;
const
hrefRegex
=
/
]*>([\s\S]*?)
/
i
;
const
displayNameRegex
=
/
]*>([\s\S]*?)
/
i
;
const
collectionRegex
=
/
]*\/?>
/
i
;
const
normalizedBase
=
basePath
.
replace
(
/
\/+$
/
,
''
)
;
let
match
;
while
(
(
match
=
responseRegex
.
exec
(
xml
)
)
!==
null
)
{
const
block
=
match
[
2
]
;
const
hrefMatch
=
hrefRegex
.
exec
(
block
)
;
if
(
!
hrefMatch
)
continue
;
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 通道:

ipcMain
.
handle
(
'svn:test-connection'
,
async
(
event
,
conn
)
=>
{
try
{
const
url
=
new
URL
(
conn
.
serverUrl
)
;
const
body
=
`
`
;
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
{
const
base
=
conn
.
serverUrl
.
endsWith
(
'/'
)
?
conn
.
serverUrl
:
conn
.
serverUrl
+
'/'
;
const
relPath
=
repoPath
.
replace
(
/
^\/+
/
,
''
)
;
const
url
=
new
URL
(
relPath
,
base
)
;
const
body
=
`

  `
;
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
)
{
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
=
`

  1
  HEAD
  ${
limit
||
50
}
  
  
  
  `
;
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
}
;
}
}
)
;
| 通道名 | 功能 | 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,以下为真实项目代码:

<
!
--
替换前
--
><
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 真实代码):

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
;
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(),以下为真实项目代码:

function
showConfirm
(
message
)
{
return
new
Promise
(
(
resolve
)
=>
{
const
overlay
=
document
.
createElement
(
'div'
)
;
overlay
.
className
=
'confirm-overlay'
;
overlay
.
innerHTML
=
`${
message
}取消确定`
;
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 转为相对路径:从绝对路径中截取相对于仓库根目录的部分

const
base
=
conn
.
serverUrl
.
endsWith
(
'/'
)
?
conn
.
serverUrl
:
conn
.
serverUrl
+
'/'
;
const
relPath
=
repoPath
.
replace
(
/
^\/+
/
,
''
)
;
const
url
=
new
URL
(
relPath
,
base
)
;
if
(
hrefNoSlash
.
startsWith
(
normalizedBase
+
'/'
)
)
{
relativeHref
=
hrefNoSlash
.
slice
(
normalizedBase
.
length
+
1
)
;
}

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

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

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

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

const
responseRegex
=
/
]*>([\s\S]*?)
/
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 协议实现,无需关心平台差异。



转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:CSDN HarmonyOS

原创地址:https://blog.csdn.net/weixin_43151418/article/details/165006664

原文作者:xcLeigh;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-08-02

  • 博文量
    36
  • 访问量
    34893