kdesvn 是 KDE 桌面环境下经典的 Subversion 图形客户端。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个 KDE 风格的轻量级 SVN 只读浏览工具——不依赖原生 svn 命令行和 libsvn 库,通过纯 JavaScript 实现 HTTP/WebDAV 协议通信,完成仓库浏览、文件语法高亮查看、日志检索、版本对比等核心功能,并集成 Catppuccin Mocha 深色主题,打造现代化视觉体验。

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

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

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

一、技术架构分析

1.1 为什么选择 HTTP/WebDAV 协议

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      ┌──────────────────┐
│  kdesvn     │  ──────────────────▶ │  SVN Server      │
│  (JavaScript)│                      │  (Apache/VSFS)   │
│              │  PROPFIND (列目录)   │                   │
│              │  GET (获取文件)      │  mod_dav_svn     │
│              │  REPORT (查日志)     │                   │
└──────────────│  ← XML Response ─── │                   │
│  + 302 重定向│                      │                   │
└─────────────┘                      └──────────────────┘

核心 HTTP 方法及其用途:

HTTP 方法SVN 用途说明
PROPFIND列目录、测试连接获取资源属性(名称、类型、大小)
GET获取文件内容支持 302 重定向跟随 + !svn/bc/ 降级路径
REPORT获取提交日志3 级降级策略:!svn/me → 资源路径 → PROPFIND 验证

1.4 核心功能清单

功能模块具体能力
连接管理添加/删除连接、测试连接、配置持久化、预置 4 个公共仓库
仓库浏览PROPFIND 列目录、目录树导航、返回上级、路径显示、竞态条件防护
文件查看highlight.js 语法高亮(40+ 语言)、HTTP 302 重定向跟随
日志检索REPORT 获取提交历史、3 级降级策略、日志搜索过滤
版本对比双版本 Diff 对比、变更路径展示
主题配色Catppuccin Mocha 深色主题、20 个 CSS 变量系统

二、环境准备

2.1 开发环境要求

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

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── kdesvn/                           # kdesvn 客户端源码(开发目录)
│       ├── main.js                       # Electron 主进程(SVN HTTP 引擎 + IPC + 配置持久化)
│       ├── renderer.js                   # 渲染进程(UI 交互 + 连接管理 + 目录浏览 + 文件查看 + 日志)
│       ├── index.html                    # HTML 页面结构(三栏布局)
│       ├── package.json                  # 项目配置
│       └── styles/
│           └── kdesvn.css                # Catppuccin Mocha 深色主题 + highlight.js 配色覆盖
├── 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/kdesvn/ 中开发,每次修改后同步到 web_engine/src/main/resources/resfile/resources/app/ 部署目录。

三、核心适配流程

3.1 创建 Electron 主进程

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

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

// kdesvn Subversion 客户端 主进程
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'), 'kdesvn-connections.json');

// 默认公共 SVN 仓库(首次启动时自动加载)
const DEFAULT_CONNECTIONS = [
  {
    id: 'default-apache-svn',
    name: 'Apache Subversion (国际)',
    serverUrl: 'https://svn.apache.org/repos/asf/subversion/trunk',
    username: '',
    password: ''
  },
  {
    id: 'default-apache-httpd',
    name: 'Apache HTTP Server (国际)',
    serverUrl: 'https://svn.apache.org/repos/asf/httpd/httpd/trunk',
    username: '',
    password: ''
  },
  {
    id: 'default-gcc',
    name: 'GCC (国际)',
    serverUrl: 'https://gcc.gnu.org/svn/gcc/trunk',
    username: '',
    password: ''
  },
  {
    id: 'default-local-demo',
    name: '本地测试仓库 (示例)',
    serverUrl: 'http://127.0.0.1:8080/svn/demo',
    username: '',
    password: ''
  }
];

function loadConfig() {
  try {
    if (fs.existsSync(configPath)) {
      connections = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
    } else {
      // 首次启动:加载默认仓库并持久化
      connections = JSON.parse(JSON.stringify(DEFAULT_CONNECTIONS));
      saveConfig();
    }
  } catch (e) { connections = JSON.parse(JSON.stringify(DEFAULT_CONNECTIONS)); }
}

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

function createWindow() {
  const display = screen.getPrimaryDisplay();
  const { width, height } = display.workAreaSize;
  mainWindow = new BrowserWindow({
    width: Math.floor(width * 0.9),
    height: Math.floor(height * 0.85),
    webPreferences: { nodeIntegration: true, contextIsolation: false }
  });
  mainWindow.loadFile('index.html');
}

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

设计要点:

  • 首次启动自动加载 4 个默认公共仓库并持久化到 JSON 文件,用户开箱即用
  • app.disableHardwareAcceleration() 禁用 GPU 加速,防止鸿蒙平台白屏
  • 所有关键操作 try-catch 包裹,防止单点故障导致进程崩溃

3.2 SVN HTTP 请求引擎(含 302 重定向跟随)

所有 SVN 操作都通过统一的 svnRequest 函数发送 HTTP 请求。与标准 HTTP 客户端不同,SVN 服务器在 GET 文件时经常返回 302 重定向(重定向到带版本号的路径),而 Node.js 的 http/https 模块不会自动跟随重定向,必须手动实现。以下为真实项目代码:

// SVN HTTP/WebDAV 请求引擎(支持 301/302 重定向跟随)
function svnRequest(conn, method, urlPath, headers, body, maxRedirects) {
  if (!headers) headers = {};
  if (maxRedirects === undefined) maxRedirects = 5;
  return new Promise((resolve, reject) => {
    const url = new URL(urlPath, conn.serverUrl);
    const client = url.protocol === 'https:' ? https : http;
    const reqHeaders = { 'User-Agent': 'kdesvn/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) => {
      // 处理 301/302 重定向(SVN 服务器常见行为)
      if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && maxRedirects > 0) {
        const redirectUrl = new URL(res.headers.location, conn.serverUrl);
        // 构造重定向后的 conn(更新 serverUrl 为实际域名)
        const redirectConn = { ...conn, serverUrl: redirectUrl.origin };
        // 消费响应体避免连接挂起
        res.resume();
        svnRequest(redirectConn, method, redirectUrl.pathname + redirectUrl.search, headers, body, maxRedirects - 1)
          .then(result => resolve(result))
          .catch(err => reject(err));
        return;
      }
      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(60000, () => { req.destroy(); reject(new Error('请求超时(60秒),请检查网络或仓库地址')); });
    if (body) req.write(body);
    req.end();
  });
}

设计要点:

特性说明
自动重定向跟随最多 5 次 301/302 递归跟随,携带认证头
60 秒超时适配国际 SVN 服务器(国内访问延迟高)
自签名证书支持rejectUnauthorized: false
条件认证仅在有用户名时发送 Authorization 头(空认证头会触发 Apache 401)
协议自适应根据 URL 自动选择 http 或 https 模块

3.3 XML 响应解析(灵活命名空间兼容)

SVN 服务器返回的 XML 使用不同的命名空间前缀(D:、d:、dav: 或无前缀),解析时必须灵活匹配。以下为 PROPFIND 响应解析的真实项目代码:

// 解析 PROPFIND XML 响应
function parsePropfind(xml, basePath) {
  const items = [];
  // 使用 (\w+:)? 灵活匹配任意命名空间前缀
  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;
  const getLenRegex = /<(\w+:)?getcontentlength[^>]*>([\s\S]*?)<\/(\w+:)?getcontentlength>/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 lenMatch = getLenRegex.exec(block);
    const size = lenMatch ? parseInt(lenMatch[4] || lenMatch[2] || '0', 10) : 0;

    items.push({ name: name, href: relativeHref, isDirectory: isDir, size: size });
  }
  items.sort((a, b) => {
    if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
    return a.name.localeCompare(b.name);
  });
  return items;
}

关键点:使用 (\w+:)? 正则模式匹配任意 XML 命名空间前缀,确保兼容 Apache mod_dav_svn、VisualSVN Server、svn.apache.org 等不同实现。

3.4 日志获取 3 级降级策略

不同 SVN 服务器对 REPORT 请求的支持程度不同。部分服务器不支持 /!svn/me 端点,导致日志查询返回 404。本客户端实现了 3 级降级策略,确保最大兼容性。以下为真实项目代码:

// 获取提交日志(3 级降级策略)
ipcMain.handle('svn:get-log', async (event, conn, repoPath, limit) => {
  // ... 构建 log-report XML body ...

  // 路径 1:标准 /!svn/me 端点(Apache mod_dav_svn)
  const logPath = url.pathname + '/!svn/me';
  const res = await svnRequest(conn, 'REPORT', logPath,
    { 'Content-Type': 'application/xml' }, body);
  if (res.statusCode === 200) {
    return { success: true, logs: parseLogReport(res.body) };
  }

  // 路径 2:直接在资源路径上 REPORT(部分 SVN 服务器支持)
  if (res.statusCode === 404) {
    const res2 = await svnRequest(conn, 'REPORT', url.pathname,
      { 'Content-Type': 'application/xml' }, body);
    if (res2.statusCode === 200) {
      return { success: true, logs: parseLogReport(res2.body) };
    }
  }

  // 路径 3:PROPFIND 验证可达性 → 返回空日志 + 说明
  if (res.statusCode === 404) {
    const propBody = '<?xml version="1.0" encoding="utf-8"?>' +
      '<D:propfind xmlns:D="DAV:">' +
      '<D:prop><D:resourcetype/></D:prop></D:propfind>';
    const res3 = await svnRequest(conn, 'PROPFIND', url.pathname,
      { 'Depth': '0', 'Content-Type': 'application/xml' }, propBody);
    if (res3.statusCode === 207 || res3.statusCode === 200) {
      return { success: true, logs: [],
        note: '当前 SVN 服务器不支持日志查询,目录浏览和文件查看功能正常' };
    }
  }

  return { success: false,
    message: '获取日志失败: HTTP ' + res.statusCode };
});

3 级降级策略说明:

级别请求路径适用场景结果
1REPORT /path/!svn/meApache mod_dav_svn(标准)正常返回日志
2REPORT /path/部分 SVN 服务器正常返回日志
3PROPFIND /path/ 验证可达不支持 REPORT 的服务器返回空日志 + 说明提示

3.5 文件内容获取(含降级路径)

文件 GET 请求同样需要降级处理。部分 SVN 服务器不支持直接 GET,需要尝试 !svn/bc/HEAD/ 基线路径。以下为真实项目代码:

// 获取文件内容(含降级路径)
ipcMain.handle('svn:get-file', async (event, conn, filePath, revision) => {
  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 };
  }

  // 降级:尝试 !svn/bc/ 基线路径
  if (res.statusCode === 404 && !revision) {
    const parts = urlPath.split('/').filter(Boolean);
    const bcPath = '/' + parts.slice(0, -1).join('/')
      + '/!svn/bc/HEAD/' + parts[parts.length - 1];
    const res2 = await svnRequest(conn, 'GET', bcPath, {}, null);
    if (res2.statusCode === 200) {
      return { success: true, content: res2.body };
    }
  }

  return { success: false, message: '文件获取失败: HTTP ' + res.statusCode };
});

3.6 IPC 通道设计

主进程与渲染进程通过 9 个 IPC 通道通信,职责清晰分离:

通道名称方向功能
svn:test-connection渲染→主PROPFIND Depth:0 测试连接可达性
svn:list-dir渲染→主PROPFIND Depth:1 列出目录内容
svn:get-file渲染→主GET 获取文件内容(含重定向+降级)
svn:get-log渲染→主REPORT 获取提交日志(3 级降级)
config:get-connections渲染→主读取持久化连接配置
config:save-connection渲染→主保存/更新连接配置
config:delete-connection渲染→主删除连接配置
dialog:saveFile渲染→主文件保存对话框(预留)
file:write渲染→主写入本地文件(预留)

四、UI 设计与主题实现

4.1 Catppuccin Mocha 深色主题

kdesvn 采用 Catppuccin Mocha 配色方案,通过 20 个 CSS 变量实现全局统一。以下为真实 CSS 代码:

:root {
  --base: #1e1e2e;       /* 主背景色 */
  --mantle: #181825;     /* 次级背景色(侧边栏、面板头) */
  --crust: #11111b;      /* 最深色(状态栏) */
  --surface0: #313244;   /* 表面色(边框、输入框) */
  --surface1: #45475a;   /* 高亮表面色(悬停状态) */
  --surface2: #585b70;   /* 更深表面色(拖动条) */
  --text: #cdd6f4;       /* 主文字色 */
  --text-muted: #a6adc8; /* 次要文字色 */
  --overlay0: #6c7086;   /* 覆盖层色(禁用态、注释) */
  --blue: #89b4fa;       /* 蓝色(链接、函数名) */
  --green: #a6e3a1;      /* 绿色(成功、字符串) */
  --red: #f38ba8;        /* 红色(错误、关键字) */
  --yellow: #f9e2af;     /* 黄色(警告、类名) */
  --mauve: #cba6f7;      /* 紫色(标签、正则) */
  --peach: #fab387;      /* 桃色(数字) */
  --teal: #94e2d5;       /* 青色 */
  --sky: #89dceb;        /* 天蓝(属性) */
  --border: #313244;     /* 边框色 */
  --border-light: #45475a;
}

4.2 三栏可拖动布局

界面采用经典的三栏布局:左侧边栏(连接管理 + 目录树)+ 右侧主区域(文件浏览/查看/对比 + 底部日志面板),支持侧边栏和底部面板的拖动调整大小。

┌──────────────────────────────────────────────────────────┐
│  🔀 kdesvn    [检出] [更新] [提交] | [责任] [对比] [日志] │  ← 工具栏
├──────────┬───────────────────────────────────────────────┤
│ 连接管理  │  [文件浏览] [文件查看] [版本对比]              │
│ 📦 Apache│  ┌──────────────────────────────────────────┐ │
│ 📦 HTTPD │  │ 名称          类型    大小               │ │
│ 📦 GCC   │  │ 📁 branches/   目录    -                 │ │
│ 📦 本地   │  │ 📁 tags/       目录    -                 │ │
│──────────│  │ 📜 README      文件    2.3 KB            │ │
│ 目录浏览  │  │ 📜 setup.py    文件    5.1 KB            │ │
│ /trunk   │  └──────────────────────────────────────────┘ │
│  ▶ src   │───────────────────────────────────────────────│
│  ▶ tests │  提交日志              [搜索...]              │
│          │  r1920456 | admin | 2024-01-15               │
│          │  Fix build configuration                      │
├──────────┴───────────────────────────────────────────────┤
│ 已加载 12 个项目              kdesvn v1.0 | SVN 客户端   │
└──────────────────────────────────────────────────────────┘

4.3 highlight.js 语法高亮集成

文件查看器集成 highlight.js 实现代码语法高亮,支持 40+ 编程语言。通过文件扩展名自动匹配语言类型,并使用 Catppuccin Mocha 配色覆盖高亮颜色。以下为渲染进程真实代码:

// 文件扩展名 → highlight.js 语言映射
var EXT_LANG_MAP = {
  js: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby',
  java: 'java', kt: 'kotlin', cpp: 'cpp', c: 'cpp', h: 'cpp', cc: 'cpp',
  cs: 'csharp', go: 'go', rs: 'rust', php: 'php', swift: 'swift',
  html: 'xml', xml: 'xml', svg: 'xml', xsl: 'xml', xhtml: 'xml',
  css: 'css', scss: 'scss', less: 'less', sql: 'sql',
  sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell',
  json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini',
  md: 'markdown', dockerfile: 'dockerfile', makefile: 'makefile',
  cmake: 'cmake', r: 'r', lua: 'lua', perl: 'perl', pl: 'perl',
  scala: 'scala', dart: 'dart', vim: 'vim'
};
function getHighlightLang(fileName) {
  var ext = fileName.split('.').pop().toLowerCase();
  if (ext === fileName.toLowerCase()) {
    // 无扩展名文件:按文件名匹配
    var lower = fileName.toLowerCase();
    if (lower === 'makefile' || lower === 'gnumakefile') return 'makefile';
    if (lower === 'dockerfile') return 'dockerfile';
    if (lower === 'cmakelists.txt') return 'cmake';
    return '';
  }
  return EXT_LANG_MAP[ext] || '';
}

// 文件查看
async function viewFile(filePath, fileName) {
  if (!activeConn) return;
  selectedFileName = fileName || filePath.split('/').pop();
  switchTab('fileview');
  getElementById('fileViewName').textContent = selectedFileName;
  getElementById('fileViewInfo').textContent = '加载中...';
  var codeEl = getElementById('fileContent');
  codeEl.textContent = '';
  codeEl.className = 'file-content';

  const result = await ipcRenderer.invoke('svn:get-file', activeConn, filePath);
  if (result.success) {
    codeEl.textContent = result.content;
    const lines = result.content.split('\n').length;
    getElementById('fileViewInfo').textContent = lines + ' 行 | ' + formatSize(result.content.length);
    // 语法高亮
    var lang = getHighlightLang(selectedFileName);
    if (lang) codeEl.classList.add('language-' + lang);
    if (typeof hljs !== 'undefined') {
      try { hljs.highlightElement(codeEl); } catch (e) { /* 高亮失败不影响显示 */ }
    }
  } else {
    codeEl.textContent = '获取文件失败: ' + result.message;
    getElementById('fileViewInfo').textContent = '错误';
  }
}

高亮颜色与 Catppuccin Mocha 的映射关系:

语法元素CSS 变量颜色效果
关键字 (def/class/if)–red红色
字符串 (“hello”)–green绿色
数字 (42/3.14)–peach桃色
注释 (# comment)–overlay0 + 斜体灰色斜体
函数名 (my_func)–blue蓝色
类名 (MyClass)–yellow黄色
属性/装饰器–sky天蓝色
标签/选择器–mauve紫色

4.4 自定义通知组件(替代 alert)

鸿蒙平台原生 alert() 会触发 SubWindow 崩溃,因此用纯 HTML/CSS 实现通知提示。以下为真实项目代码:

// 自定义通知(替代 alert,防止鸿蒙 SubWindow 崩溃)
function showNotification(title, message, type) {
  var existing = getElementById('kdesvnNotification');
  if (existing) existing.remove();

  var div = document.createElement('div');
  div.id = 'kdesvnNotification';
  div.className = 'kdesvn-notification' + (type === 'error' ? ' error' : type === 'success' ? ' success' : '');
  div.innerHTML = '<div class="notification-title">' + escapeHtml(title) + '</div>' +
    '<div class="notification-msg">' + escapeHtml(message) + '</div>';
  document.body.appendChild(div);
  setTimeout(function() { if (div.parentNode) div.remove(); }, 4000);
}

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

5.1 鸿蒙三防策略

鸿蒙 Electron 适配层 libadapter.so 存在平台级限制,必须从源头避免触发原生 SubWindow:

防护目标措施代码位置
防 XComponent 崩溃app.disableHardwareAcceleration()main.js 启动时
防原生弹窗崩溃禁用 prompt/confirm/alert,自定义通知替代renderer.js 全局
防渲染引擎异常不使用 setWindowOpenHandler/will-navigatemain.js 窗口配置

代码验证(确保零违规):

// renderer.js 中禁止出现以下调用:
// ❌ alert('message')        → 触发 SubWindow 崩溃
// ❌ confirm('sure?')        → 触发 SubWindow 崩溃
// ❌ prompt('input:')        → 触发 SubWindow 崩溃
// ❌ <select>...</select>    → 触发 SubWindow 崩溃

// ✅ 正确做法:
// showNotification('提示', '消息', 'error')  → 自定义 div 通知
// 自定义 div 模态框                           → 替代 confirm/prompt

5.2 目录导航竞态条件修复

问题现象:快速连续点击两个目录时,第一个请求还没返回就发了第二个,如果第一个请求后返回会覆盖正确的状态,导致"加载失败"。

根本原因:两个 PROPFIND 请求并发,响应到达顺序不确定,旧请求的响应覆盖了新请求的结果。

解决方案:引入请求 ID 计数器,每次 browseDirectory 调用递增计数器,响应到达时检查是否为最新请求,丢弃过期响应。以下为真实项目代码:

let navRequestId = 0;  // 导航请求计数器

async function browseDirectory(dirPath) {
  if (!activeConn) return;
  // 生成请求 ID,用于防止竞态条件
  const thisRequestId = ++navRequestId;
  currentDirPath = dirPath;
  getElementById('currentPath').textContent = dirPath;
  getElementById('btnParentDir').disabled = (dirPath === '/');
  getElementById('statusLeft').textContent = '正在加载目录...';

  const result = await ipcRenderer.invoke('svn:list-dir', activeConn, dirPath);
  // 仅应用最新请求的结果,丢弃过期的响应
  if (thisRequestId !== navRequestId) return;
  if (result.success) {
    renderDirectoryTree(result.items);
    renderFileList(result.items);
    getElementById('statusLeft').textContent = '已加载 ' + result.items.length + ' 个项目';
  } else {
    getElementById('statusLeft').textContent = '加载失败:' + result.message;
    showNotification('加载失败', result.message, 'error');
  }
}

5.3 面板显隐 Bug 修复(CSS class vs inline style)

问题现象:点击文件后跳转到"文件查看"标签页,但内容为空。

根本原因:CSS 通过 .tab-panel { display: none } 和 .tab-panel.active { display: flex } 控制面板显隐,但 JavaScript 使用 inline style.display 切换。当设置 display = ‘’ 时,inline style 被移除,CSS 的 display: none 生效,面板永远不可见。

解决方案:将 inline style 切换改为 classList.toggle(‘active’),与 CSS 规则对齐。

// ❌ 修复前(inline style 与 CSS class 冲突)
function switchTab(tabName) {
  panelFileview.style.display = tabName === 'fileview' ? '' : 'none';
  // display='' 移除 inline style → CSS display:none 生效 → 面板不可见
}

// ✅ 修复后(使用 class 切换,与 CSS 一致)
function switchTab(tabName) {
  var tabs = getElementById('contentTabs').children;
  for (var i = 0; i < tabs.length; i++) {
    tabs[i].classList.toggle('active', tabs[i].dataset.tab === tabName);
  }
  // 面板通过 .active 类控制显隐(CSS: .tab-panel{display:none} .tab-panel.active{display:flex})
  getElementById('panelBrowser').classList.toggle('active', tabName === 'browser');
  getElementById('panelFileview').classList.toggle('active', tabName === 'fileview');
  getElementById('panelDiff').classList.toggle('active', tabName === 'diff');
}

这个 Bug 同时影响"文件查看"和"版本对比"两个面板。修复后所有面板切换正常工作。

六、文件同步部署

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

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

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

最终部署文件清单:

文件说明
main.js主进程(SVN HTTP 引擎 + IPC + 配置持久化)
renderer.js渲染进程(UI + 连接管理 + 目录浏览 + 文件查看 + 日志)
index.html页面结构(三栏布局 + highlight.js CDN)
styles/kdesvn.cssCatppuccin Mocha 主题 + highlight.js 配色覆盖
package.json项目配置

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

七、可测试的公开 SVN 仓库

本客户端预置了 4 个默认连接,首次启动自动加载:

名称服务器地址说明
Apache Subversionhttps://svn.apache.org/repos/asf/subversion/trunkSVN 自身源码
Apache HTTP Serverhttps://svn.apache.org/repos/asf/httpd/httpd/trunkApache HTTPD
GCChttps://gcc.gnu.org/svn/gcc/trunkGCC 编译器
本地测试仓库http://127.0.0.1:8080/svn/demo本地搭建测试

其他可测试的公开 SVN 仓库:

名称服务器地址说明
Apache Mavenhttps://svn.apache.org/repos/asf/maven/Maven 构建工具
Apache Tomcathttps://svn.apache.org/repos/asf/tomcat/Tomcat 服务器
CPythonhttps://svn.python.org/projects/python/Python 源码

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

注意:国际 SVN 服务器在国内访问可能超时(默认 60 秒),建议使用国内或内网 SVN 服务器获得更好体验。

八、常见问题与解决方案

Q1:文件查看内容为空

问题现象:点击文件后跳转到文件查看标签页,但看不到任何内容

根本原因 1:SVN 服务器对 GET 请求返回 302 重定向,Node.js http 模块不自动跟随

解决方案:svnRequest 函数增加 301/302 重定向跟随逻辑,最多递归 5 次

// 处理 301/302 重定向(SVN 服务器常见行为)
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location && maxRedirects > 0) {
  const redirectUrl = new URL(res.headers.location, conn.serverUrl);
  // 构造重定向后的 conn(更新 serverUrl 为实际域名)
  const redirectConn = { ...conn, serverUrl: redirectUrl.origin };
  // 消费响应体避免连接挂起
  res.resume();
  svnRequest(redirectConn, method, redirectUrl.pathname + redirectUrl.search, headers, body, maxRedirects - 1)
    .then(result => resolve(result))
    .catch(err => reject(err));
  return;
}

根本原因 2:CSS 通过 .active 类控制面板显隐,JS 使用 inline style 切换,两者冲突

解决方案:统一使用 classList.toggle(‘active’) 切换面板显隐

Q2:日志获取返回 404

问题现象:连接成功、目录浏览正常,但提交日志显示"获取日志失败,404"

根本原因:/!svn/me 端点不是所有 SVN 服务器都支持

解决方案:实现 3 级降级策略(详见 3.4 节),最终通过 PROPFIND 验证服务器可达性后返回空日志 + 说明提示

Q3:连续点击目录显示"加载失败"

问题现象:快速点击目录 A 再点击目录 B,偶尔显示"加载失败"

根本原因:竞态条件 — 两个 PROPFIND 请求并发,旧请求的响应覆盖新请求的结果

解决方案:引入请求 ID 计数器(详见 5.2 节),响应到达时检查是否为最新请求

Q4:连接超时 30 秒

问题现象:国际 SVN 服务器连接超时

解决方案:将请求超时从 30 秒增加到 60 秒,并在错误信息中提供网络检查提示

req.setTimeout(60000, () => {
  req.destroy();
  reject(new Error('请求超时(60秒),请检查网络或仓库地址'));
});

Q5:PROPFIND 请求被服务器拒绝

问题现象:目录浏览返回错误

根本原因:PROPFIND 请求体包含 SVN 专有属性(如 S:baseline-relative-path),部分服务器不认识会拒绝整个请求

解决方案:移除 SVN 专有属性,只使用标准 DAV 属性(displayname、resourcetype、getcontentlength)

// 仅使用标准 DAV 属性,兼容所有 SVN 服务器
const body = '<?xml version="1.0" encoding="utf-8"?>' +
  '<D:propfind xmlns:D="DAV:">' +
  '<D:prop><D:displayname/><D:resourcetype/>' +
  '<D:getcontentlength/></D:prop>' +
  '</D:propfind>';

九、总结

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

技术点方案
SVN 通信协议HTTP/WebDAV(PROPFIND + GET + REPORT)
HTTP 重定向自动跟随 301/302(最多 5 次递归)
日志获取3 级降级策略(!svn/me → 资源路径 → PROPFIND 验证)
XML 解析正则表达式 + 灵活命名空间前缀 (\w+:)? 匹配
语法高亮highlight.js 11.9.0 + 40+ 语言映射
主题配色Catppuccin Mocha 深色主题(20 个 CSS 变量)
竞态防护请求 ID 计数器 + 过期响应丢弃
配置持久化JSON 文件存储于 userData 目录
鸿蒙稳定性三防策略 + 自定义通知替代原生弹窗
面板显隐CSS .active 类统一控制(修复 inline style 冲突)
超时设置60 秒(适配国际服务器)

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

另一个重要教训是 CSS 与 JavaScript 的显隐机制必须统一:当 CSS 通过 class 控制 display 属性时,JavaScript 也必须通过 class 切换,不能使用 inline style,否则会产生优先级冲突导致面板不可见。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐