欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_subversion
| 协议 | 端口 | 特点 | | --- | --- | --- | | 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:// 协议。
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程 的分层架构:
Electron Web 层:运行于 ArkWeb 引擎,提供完整的 SVN 客户端功能(连接管理、仓库浏览、文件查看、日志检索、版本对比)
鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、网络访问、配置持久化等系统能力
┌─────────────┐ 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 请求 || 功能模块 | 具体能力 | | --- | --- | | 连接管理 | 添加/编辑/删除连接、测试连接、配置持久化存储 | | 仓库浏览 | PROPFIND 列目录、目录树导航、返回上级、路径显示 | | 文件查看 | CodeMirror 语法高亮、指定版本查看 | | 日志检索 | REPORT 获取提交历史、显示作者/日期/提交信息 | | 版本对比 | 双版本 Diff 对比、CodeMirror 合并视图 |
| 项目 | 版本/信息 | | --- | --- | | 操作系统 | Windows 10/11 | | 核心框架 | Electron (Node.js + Chromium) | | 技术栈 | HTML5/CSS3/Vanilla JavaScript | | 编辑器 | CodeMirror 5.65.16 | | 目标设备 | 鸿蒙 PC | | 目标架构 | arm64-v8a | | 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
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/ 部署目录。
文件: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 | 允许调整 | 符合鸿蒙窗口管理规范 |
所有 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 支持自签名证书的内部仓库
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+:)? 正则模式可以灵活匹配所有情况。
主进程通过 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 | 删除连接配置 | 本地文件写入 |
文件: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 切换
连接管理支持添加、编辑、删除、测试连接,配置持久化到本地 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 存在一些平台级限制,需要针对性地绕过。
问题现象:点击连接选择下拉框,第一次正常,第二次点击直接闪退
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'
)
)
;
}
)
;问题现象:删除连接时弹出确认对话框,触发同样的 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
(
)
;
}
}
;问题现象:添加 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()| 防护目标 | 措施 | 代码位置 | | --- | --- | --- | | 防 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 包不会包含最新代码。


| 名称 | 服务器地址 | 说明 | | --- | --- | --- | | 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 源码 |
这些都是只读公开仓库,无需认证,可以直接测试目录浏览、文件查看、日志加载等功能。
问题现象: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
}
`
;
}根本原因: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
)
;
}问题现象:部分服务器返回的 XML 解析结果为空
根本原因:不同 SVN 服务器使用不同的 XML 命名空间前缀(D:、d:、dav: 或无前缀),正则表达式写死了特定前缀
解决方案:使用 (\w+:)? 灵活匹配任意命名空间前缀
const responseRegex = / ]*>([\s\S]*?) / gi ;
问题现象: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;如有侵权,请联系管理员删除。