鸿蒙PC平台智码编程助手适配实战:基于 Electron 壳方案的纯 JavaScript 本地 IDE 与项目概览引擎开发
智码编程助手是一款纯 JavaScript 实现的本地集成开发环境,提供文件资源管理器、多标签代码编辑器(支持 20 余种语言语法高亮)、项目概览面板(文件统计、两级目录树、最近修改列表、文件类型分布条形图)、拖拽分隔条三栏布局、右键上下文菜单、未保存关闭确认等完整 IDE 能力。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备项目浏览、代码查看与编辑、文件重命名与删除、原生目录选择、项目概览分析的完整过程——涵盖 contextBridge 安全通信模型、单通道动作路由、Workspace 沙箱路径校验、多标签编辑器状态管理、语法高亮引擎、项目递归扫描与可视化、以及 Catppuccin 深色主题等核心功能的实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_PiAgent
一、技术架构分析
1.1 整体架构
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:
- Electron Web 层:运行于 ArkWeb 引擎,提供完整的文件资源管理、多标签代码编辑、语法高亮、项目概览分析、拖拽分隔条布局与文件操作能力
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、原生目录选择对话框、屏幕自适应等系统能力
这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。与典型的 Electron 应用不同,本工程采用 contextBridge 安全通信模型——渲染进程完全不接触 Node.js API,所有文件操作通过预加载脚本暴露的受控桥接器发起,主进程内的 Workspace 类执行严格的路径沙箱校验后才真正读写文件系统。
1.2 技术方案
| 对比项 | 传统 IDE(VS Code / WebStorm) | 鸿蒙适配版(智码编程助手) |
|---|---|---|
| 语言支持 | 数百种语言 + LSP 智能补全 | 20 余种语言语法高亮(纯前端正则) |
| 文件操作 | 扩展 API + 本地文件系统 | Workspace 沙箱类 + IPC 动作路由 |
| 编辑器 | Monaco / CodeMirror 组件 | 原生 textarea + 透明叠加高亮层 |
| 项目分析 | 内置终端 + 调试器 + Git 集成 | 本地递归扫描 + 文件统计 + 分布图表 |
| 安全模型 | 扩展沙箱 + 信任工作区 | contextBridge + CSP + 路径沙箱三重防护 |
| 部署方式 | 安装包 | HAP 包(鸿蒙应用) |
| 运行环境 | Windows/macOS/Linux | 鸿蒙 PC(ArkWeb 引擎) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| 文件资源管理 | 目录树展开/折叠、文件类型徽章(30 余种扩展名映射)、点击打开 |
| 多标签编辑器 | 标签页增删切换、未保存标记(圆点/关闭符号动态切换)、Ctrl+S 保存 |
| 语法高亮 | 20 余种语言正则分词(关键字/字符串/数字/注释四类 token) |
| 代码编辑 | textarea 透明叠加 + 高亮层同步滚动、Tab 缩进、行号联动 |
| 文件操作 | 重命名、删除(自研递归删除,兼容鸿蒙旧版 Node)、未保存关闭确认 |
| 项目概览 | 文件/目录/类型统计卡片、两级目录树、最近修改列表(mtime 排序) |
| 分布图表 | Top 10 扩展名条形图(16 色循环配色) |
| 拖拽分隔条 | 左右两条分隔条,鼠标拖拽调整面板宽度(含边界约束) |
| 右键菜单 | 文件/目录右键重命名与删除确认 |
| 原生目录选择 | dialog.showOpenDialog 选择项目目录 |
| 安全通信 | contextBridge 白名单动作 + CSP connect-src:none + 路径沙箱 |
| 鸿蒙稳定 | 关于弹窗 + 三防窗口配置 + 禁用原生菜单 + Toast 反馈 |
二、环境准备



2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/Vanilla JavaScript |
| 渲染引擎 | 原生 textarea + 高亮叠加层 |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron/ # Electron 壳
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/ # 部署目录(业务代码平铺)
│ ├── main.js # 主进程(窗口管理 + 单通道动作路由 IPC)
│ ├── preload.js # 预加载脚本(contextBridge 安全桥接)
│ ├── renderer.js # 渲染进程(UI 交互 + 编辑器 + 文件树 + 概览)
│ ├── index.html # 页面布局(三栏 + 状态栏 + 弹层 + 对话框)
│ ├── package.json # 项目配置
│ ├── logo.png # 应用图标(面板 logo + 欢迎区大图)
│ ├── lib/
│ │ ├── service.cjs # 后端服务层(会话管理 + 设置 + 工作区调度)
│ │ └── tools.cjs # 工具层(Workspace 沙箱类 + 文件操作 + 搜索)
│ ├── runtime/
│ │ ├── pi-sdk.cjs # Agent SDK(esbuild 打包产物,不可修改)
│ │ ├── provenance.json # 上游来源记录
│ │ └── THIRD_PARTY_LICENSES.txt
│ └── styles/
│ └── pi.css # 深色主题样式(Flex 三栏 + 弹层 + 徽章)
└── AppScope/ # 应用配置(bundleName: com.wei.zhima)
2.3 依赖说明
| 依赖 | 版本 | 用途 |
|---|---|---|
| esbuild | 0.28.2 | 打包 Agent SDK 为单文件产物 |
| openai | 7.19.0 | OpenAI 兼容协议客户端 |
| partial-json | 0.1.7 | 流式 JSON 增量解析 |
| typebox | 1.3.27 | 工具参数 JSON Schema 类型定义 |
| jsdom | 26.1.0 | 服务端 DOM 环境(测试用) |
2.4 应用身份信息
| 配置项 | 值 |
|---|---|
| 应用名 | 智码编程助手 |
| bundleName | com.wei.zhima |
| vendor | wei |
| 版本名 | 1.0.0 |
| 版本号 | 1000000 |
身份五处同步:AppScope/app.json5 的 bundleName 与 vendor、AppScope string.json 的 app_name、electron 模块 base/en_US/zh_CN 三套 EntryAbility_label 均为"智码编程助手"。
2.5 string.json 格式
鸿蒙资源文件使用数组格式:
{
"string": [
{
"name": "EntryAbility_label",
"value": "智码编程助手"
}
]
}
base/en_US/zh_CN 三套 string.json 的 EntryAbility_label 值均为"智码编程助手",鸿蒙系统按 locale 目录优先级覆盖——中文环境加载 zh_CN,英文环境加载 en_US,其他语言回退到 base。
2.6 构建流程
- 在 DevEco Studio 中导入 ohos_hap 工程
- 配置 Release 签名(.p12 密钥库 + .p7b 描述文件)
- 同步运行配置的 Bundle Name 与 app.json5 一致(com.wei.zhima)
- Build > Build Hap(s)/APP(s) 生成签名 HAP 包
- 通过 hdc install 或 AppGallery Connect 提交部署
三、核心适配流程
3.1 主进程:单通道动作路由架构
与传统 Electron 应用为每种文件操作注册独立 IPC 通道不同,本工程采用单通道动作路由模式——全部 16 种操作通过 pi:request 一个通道分发,由主进程的 switch-case 路由到对应服务方法。
// main.js —— 单通道动作路由
ipcMain.handle('pi:request', async (event, action, input) => {
try {
if (!mainWindow || event.sender !== mainWindow.webContents || event.sender.getURL().split('#')[0] !== indexUrl || (event.senderFrame && event.senderFrame !== event.sender.mainFrame)) throw new Error('拒绝非应用页面请求');
if (!service) throw new Error(startupError || '后端尚未就绪');
let data;
switch (action) {
case 'state': data = service.snapshot(); break;
case 'settings': data = service.saveSettings(input); break;
case 'new': data = service.newSession(); break;
case 'open': data = service.openSession(input); break;
case 'delete': data = service.deleteSession(input); break;
case 'workspace': data = service.setWorkspace(input); break;
case 'chooseWorkspace': {
service.ensureIdle();
const result = await dialog.showOpenDialog(mainWindow, { title: '选择允许智码访问的项目目录', properties: ['openDirectory'] });
data = result.canceled || !result.filePaths.length ? null : service.setWorkspace(result.filePaths[0]);
break;
}
case 'list': data = service.workspace().list(input || '.'); break;
case 'read': data = service.workspace().read(input); break;
case 'write': service.ensureIdle(); data = service.workspace().write(input.path, input.content); break;
case 'rename': service.ensureIdle(); data = service.workspace().rename(input.path, input.newName); break;
case 'remove': service.ensureIdle(); data = service.workspace().remove(input); break;
case 'send': data = await service.send(input); break;
case 'testConnection': data = await service.testConnection(); break;
case 'stop': service.stop(); data = true; break;
case 'approve': service.resolveApproval(input.id, input.allow); data = true; break;
default: throw new Error('未知操作');
}
return { ok: true, data };
} catch (error) {
const detail = error.cause ? (error.cause.message || String(error.cause)) : '';
const msg = detail ? error.message + ' (' + detail + ')' : error.message;
return { ok: false, error: service ? service.redact(msg) : String(msg) };
}
});
该设计的核心要点:
- 来源校验:event.sender 必须等于 mainWindow.webContents,URL 的 hash 前缀必须匹配 indexUrl,senderFrame 必须是主框架——三重条件防止任何非应用页面或子框架注入请求
- 错误脱敏:redact 方法将错误消息中可能包含的 API Key 替换为 [已隐藏密钥],Bearer Token 替换为 [已隐藏]
- 互斥保护:写操作(write/rename/remove)调用前执行 ensureIdle(),防止后端忙碌时并发修改
3.2 预加载脚本:contextBridge 白名单桥接
preload.js 是渲染进程与主进程之间的唯一通信桥梁。它通过 contextBridge.exposeInMainWorld 将受控 API 暴露到 window.pi 对象,并在白名单中严格限定允许的动作名称。
// preload.js —— contextBridge 白名单桥接
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
const allowed = new Set(['state', 'settings', 'new', 'open', 'delete', 'workspace', 'chooseWorkspace', 'list', 'read', 'write', 'rename', 'remove', 'send', 'stop', 'approve', 'testConnection']);
contextBridge.exposeInMainWorld('pi', {
request(action, input) {
if (!allowed.has(action)) return Promise.resolve({ ok: false, error: '未知操作' });
return ipcRenderer.invoke('pi:request', action, input);
},
subscribe(callback) {
const listener = (_event, data) => callback(data);
ipcRenderer.on('pi:event', listener);
return () => ipcRenderer.removeListener('pi:event', listener);
}
});
白名单机制的意义在于:即使页面被注入恶意脚本,也只能调用白名单内的 16 种动作,无法构造任意 IPC 消息。subscribe 方法用于接收主进程主动推送的事件(如忙碌状态变更、审批请求等),返回取消订阅函数以便清理。
3.3 渲染进程安全通信封装
renderer.js 中对 window.pi 的调用进一步封装了错误处理与异步守卫:
// renderer.js —— 安全通信封装
async function request(action, input) {
if (!window.pi) throw new Error('安全通信桥未加载');
const result = await window.pi.request(action, input);
if (!result.ok) throw new Error(result.error || '操作失败');
return result.data;
}
const guard = (fn) => async (...args) => { try { await fn(...args); } catch (error) { status(error.message); } };
guard 高阶函数将所有异步操作包裹在 try-catch 中,异常时自动将错误信息显示在状态栏,避免未捕获异常导致页面白屏。
四、Workspace 沙箱与文件操作引擎
4.1 Workspace 类:路径安全沙箱
tools.cjs 中的 Workspace 类是所有文件操作的执行主体。它在构造时记录工作区根目录的绝对路径,每次文件访问前执行多层安全校验。
// tools.cjs —— Workspace 路径解析与安全校验
resolve(relative = '.', allowMissing = false) {
if (typeof relative !== 'string' || !relative || relative.includes('\0') || relative.includes(':') || path.isAbsolute(relative)) throw new Error('仅允许工作区内相对路径');
const parts = relative.replace(/\\/g, '/').split('/');
if (parts.some((part) => part === '..' || HIDDEN.has(part.toLowerCase()) || /^\.env(?:\.|$)/i.test(part) || /[. ]$/.test(part) && part !== '.')) throw new Error('路径或敏感目录禁止访问');
const target = path.resolve(this.root, relative);
if (!within(this.root, target) || this.protectedPaths.some((p) => within(p, target))) throw new Error('禁止访问工作区外或应用私有数据');
let current = this.root;
for (const part of path.relative(this.root, target).split(path.sep).filter(Boolean)) {
current = path.join(current, part);
let st;
try { st = fs.lstatSync(current); } catch (e) { if (allowMissing && e.code === 'ENOENT') continue; throw e; }
if (st.isSymbolicLink()) throw new Error('不访问符号链接或目录联接');
if (!within(this.root, fs.realpathSync(current))) throw new Error('路径越界');
}
return target;
}
安全校验覆盖五个维度:
- 空字节注入防御:检测 \0 字符防止路径截断攻击
- 敏感目录过滤:.git、.ssh、.aws、.gnupg、node_modules、.hvigor 以及 .env 文件一律禁止访问
- 路径穿越防御:within 函数通过 path.relative 验证目标是否仍在工作区范围内
- 应用数据保护:protectedPaths 数组包含 userData 目录和 app 目录,防止读取会话数据或应用私有配置
- 符号链接防御:逐级 lstatSync 检查路径链中每一跳,遇到符号链接立即拒绝
4.2 文件列举与 mtime 字段
list 方法在返回条目前通过 statSync 补充 mtime(修改时间戳),供前端"最近修改"列表排序使用:
// tools.cjs —— 文件列举(含 mtime 与排序)
list(relative = '.') {
const dir = this.resolve(relative);
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.filter((e) => !HIDDEN.has(e.name.toLowerCase()) && !/^\.env(?:\.|$)/i.test(e.name) && !e.isSymbolicLink()).slice(0, 500).map((e) => {
const full = path.join(dir, e.name);
let mtime = 0;
try { mtime = fs.statSync(full).mtimeMs; } catch { /* ignore */ }
return { name: e.name, path: path.relative(this.root, full), directory: e.isDirectory(), mtime };
}).sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
}
排序规则:目录优先(directory 降序),同级内按名称字母序。条目上限 500 条,防止超大目录阻塞渲染。
4.3 自研递归删除(兼容鸿蒙旧版 Node)
鸿蒙受限运行时中旧版 Node 可能不提供 fs.rmSync 的 recursive 选项,调用即抛异常导致主进程崩溃。tools.cjs 采用自研递归删除替代:
// tools.cjs —— 自研递归删除
remove(relative) {
const target = this.resolve(relative);
const removeRec = (p) => {
const st = fs.lstatSync(p);
if (st.isDirectory()) {
for (const entry of fs.readdirSync(p)) removeRec(path.join(p, entry));
fs.rmdirSync(p);
} else fs.unlinkSync(p);
};
removeRec(target);
return true;
}
先递归进入子目录清空内容,再 rmdirSync 删除空目录;文件直接 unlinkSync。全部使用旧版 Node 即有的同步 API,不依赖任何新增接口。
4.4 文件写入的排他创建保护
write 方法直接写入文件,而更高级的 change 方法(供 Agent 工具链调用)则实现了完整的排他创建流程:
// tools.cjs —— 排他创建文件防止覆盖
const flags = existed ? fs.constants.O_RDWR : fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL;
const fd = fs.openSync(target, flags | (fs.constants.O_NOFOLLOW || 0), 0o600);
try {
const st = fs.fstatSync(fd);
if (!st.isFile() || st.nlink > 1) throw new Error('目标不是可修改的普通文件');
if (existed && fs.readFileSync(fd, 'utf8') !== before) throw new Error('文件已变化,请重新读取');
const bytes = Buffer.from(newText, 'utf8');
let offset = 0;
while (offset < bytes.length) offset += fs.writeSync(fd, bytes, offset, bytes.length - offset, offset);
fs.ftruncateSync(fd, bytes.length);
fs.fsyncSync(fd);
} finally { fs.closeSync(fd); }
O_EXCL 标志确保新建时若文件已存在则失败(防止竞态覆盖),O_NOFOLLOW 拒绝打开符号链接。写入后通过 fsyncSync 强制刷盘,保证数据持久化。硬链接检测(nlink > 1)防止通过硬链接绕过沙箱修改受保护文件。
五、多标签编辑器与语法高亮引擎
5.1 标签页状态管理
renderer.js 中的 state 对象维护标签页生命周期:
// renderer.js —— 标签页状态
const state = {
directory: '.', previewPath: '', fileVersion: 0,
openTabs: [], activeTab: null
};
每个标签页对象包含 path(文件路径)、text(当前编辑内容)、original(上次保存的内容)。text !== original 时标记为"未保存",关闭时弹出确认对话框。
5.2 未保存关闭确认流程
当用户关闭有未保存更改的标签页时,系统弹出三选一确认弹层:
// renderer.js —— 未保存关闭确认
function closeTab(path) {
const tab = state.openTabs.find((t) => t.path === path);
if (tab && tab.text !== tab.original) { pendingClose = path; showCloseConfirm(tab); return; }
doCloseTab(path);
}
async function closeConfirmAction(action) {
const path = pendingClose;
if (!path) { hideCloseConfirm(); return; }
if (action === 'save') {
const tab = state.openTabs.find((t) => t.path === path);
try {
await request('write', { path, content: tab.text });
tab.original = tab.text;
status('已保存:' + shortName(path));
doCloseTab(path);
} catch (e) { showToast('保存失败:' + e.message, false); status('保存失败:' + e.message); hideCloseConfirm(); return; }
} else if (action === 'discard') { doCloseTab(path); }
hideCloseConfirm();
}
三个按钮分别对应"保存并关闭"、“不保存"和"取消”。保存失败时不关闭弹层,让用户有机会重试或取消。
5.3 标签页关闭按钮的动态标记
标签页的关闭按钮在已保存和未保存两种状态下显示不同符号,避免占位抖动:
// renderer.js —— 关闭按钮动态标记
const dirty = tab.text !== tab.original;
const close = el('span', 'editor-tab-close' + (dirty ? ' dirty' : ''));
close.appendChild(el('span', 'mark-dirty', '●'));
close.appendChild(el('span', 'mark-close', '×'));
对应 CSS 通过 .dirty 类控制显隐:已保存时只显示 ×,未保存时显示 ●(金色圆点),鼠标悬停在关闭位置时 ● 切换为 ×。固定占位宽度避免状态切换时的布局抖动。
5.4 语法高亮引擎
highlightCode 函数通过正则表达式对代码进行分词,将源码拆分为关键字、字符串、数字、注释四类 token,每个 token 包裹在带 syntax- 前缀类名的 span 中:
// renderer.js —— 语法高亮分词
function highlightCode(container, code, language) {
const lang = String(language || '').toLowerCase();
if (/^(html|htm|xml|svg|vue|jsx|tsx|md|markdown|php)$/.test(lang)) { if (lang === 'md' || lang === 'markdown') { container.textContent = code; return; } highlightMarkup(container, code); return; }
const supported = /^(js|javascript|ts|typescript|json|py|python|css|java|c|cpp|csharp|go|rust|sh|bash|sql|ruby|yaml)$/.test(lang);
if (!supported) { container.textContent = code; return; }
const regex = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b(?:const|let|var|function|return|if|else|class|import|export|from|async|await|try|catch|throw|new|true|false|null|undefined|typeof|instanceof|def|self|for|while|in|do|switch|case|break|continue|public|private|protected|static|void|int|char|float|double|bool|struct|enum|interface|extends|implements|package|require|module|this|super|yield|delete|finally|of)\b|\b\d+(?:\.\d+)?(?:[uUlLfF]*)\b)/g;
let last = 0;
for (const match of code.matchAll(regex)) {
if (match.index > last) container.appendChild(document.createTextNode(code.slice(last, match.index)));
const token = match[0];
const kind = token.startsWith('//') || token.startsWith('#') ? 'comment' : /^["'`]/.test(token) ? 'string' : /^\d/.test(token) ? 'number' : 'keyword';
container.appendChild(el('span', 'syntax-' + kind, token));
last = match.index + token.length;
}
if (last < code.length) container.appendChild(document.createTextNode(code.slice(last)));
}
语言检测通过 detectLang 函数根据文件扩展名映射,覆盖 js/mjs/cjs/ts/tsx/jsx/json/py/css/html/xml/java/c/cpp/h/cs/go/rs/sh/yaml/md/sql/rb/php 等 20 余种类型。HTML/XML 类使用独立的 highlightMarkup 函数处理标签和属性。
5.5 透明叠加高亮架构
编辑器的视觉效果由三层叠加实现:
- 底层:line-numbers 显示行号(Consolas 等宽字体,与编辑区同步滚动)
- 中层:code-highlight 绝对定位的 pre 元素,显示着色后的代码(pointer-events:none 不拦截鼠标事件)
- 顶层:code-edit 透明 textarea,文字颜色设为 transparent,仅保留 caret-color 显示光标
/* pi.css —— 高亮层与编辑区叠加 */
.code-highlight { position: absolute; left: 0; top: 0; right: 0; bottom: 0; margin: 0; padding: 12px 16px; overflow: hidden; font: 12px/1.7 Consolas, 'SFMono-Regular', monospace; tab-size: 2; white-space: pre; color: var(--text); pointer-events: none; }
.code-edit { position: absolute; left: 0; top: 0; width: 100%; height: 100%; margin: 0; padding: 12px 16px; border: none; resize: none; font: 12px/1.7 Consolas, 'SFMono-Regular', monospace; tab-size: 2; color: transparent; caret-color: #c8d3e0; background: transparent; outline: none; white-space: pre; overflow: auto; }
textarea 的 input 事件触发 updateHighlight 重新分词,scroll 事件同步高亮层与行号的滚动位置。Tab 键拦截后插入两个空格而非切换焦点。
六、项目概览面板
6.1 递归扫描器
scanProject 函数从工作区根目录开始广度优先遍历,聚合文件计数、目录计数、按扩展名分组统计和最近修改文件列表:
// renderer.js —— 项目递归扫描
async function scanProject() {
const stats = { files: 0, dirs: 0, byExt: {}, recent: [], totalSize: 0 };
const queue = ['.'];
let visited = 0;
while (queue.length && visited < 2000) {
const dir = queue.shift(); visited++;
let entries;
try { entries = await request('list', dir); } catch { continue; }
for (const entry of entries) {
if (entry.directory) { queue.push(entry.path); stats.dirs++; }
else {
stats.files++;
const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : '无扩展';
stats.byExt[ext] = (stats.byExt[ext] || 0) + 1;
if (entry.mtime) stats.recent.push({ path: entry.path, name: entry.name, mtime: entry.mtime, ext });
}
}
}
stats.recent.sort((a, b) => b.mtime - a.mtime);
stats.recent = stats.recent.slice(0, 15);
return stats;
}
遍历上限 2000 个目录,防止超大项目阻塞 UI。每个目录的 list 请求独立 try-catch,单个目录失败不影响整体扫描。最近修改文件按 mtime 降序排列取前 15 条。
6.2 四模块渲染
renderOverview 函数将扫描结果分发到四个子模块:
- renderStatsGrid:2x2 网格卡片(文件数、目录数、文件类型数、最新修改时间的相对描述)
- renderOverviewTree:两级目录树(maxDepth=2),目录显示文件夹图标,文件显示类型徽章,点击文件直接在编辑器中打开
- renderRecentFiles:最近修改文件列表,每行显示徽章 + 文件名 + 相对时间(如"3 分钟前"“2 小时前”)
- renderChart:Top 10 扩展名条形图,16 色循环配色,条宽度按比例计算
6.3 文件类型徽章系统
fileBadge 函数根据文件扩展名返回对应颜色和缩写的徽章元素,覆盖 30 余种常见类型:
// renderer.js —— 文件类型徽章映射
const map = {
js: ['JS', 'b-js'], mjs: ['JS', 'b-js'], cjs: ['JS', 'b-js'], jsx: ['JS', 'b-js'],
ts: ['TS', 'b-ts'], tsx: ['TS', 'b-ts'],
json: ['{}', 'b-json'], json5: ['{}', 'b-json'],
css: ['#', 'b-css'], scss: ['#', 'b-css'], less: ['#', 'b-css'],
html: ['<>', 'b-html'], htm: ['<>', 'b-html'], vue: ['V', 'b-html'],
md: ['M', 'b-md'], markdown: ['M', 'b-md'], txt: ['T', 'b-txt'],
py: ['PY', 'b-py'], java: ['J', 'b-java'],
c: ['C', 'b-c'], h: ['H', 'b-c'], cpp: ['C+', 'b-c'], hpp: ['C+', 'b-c'],
go: ['GO', 'b-go'], rs: ['RS', 'b-rust'], rb: ['RB', 'b-py'], php: ['PHP', 'b-html'],
sh: ['>_', 'b-sh'], bat: ['>_', 'b-sh'], ps1: ['>_', 'b-sh'],
yml: ['Y', 'b-yaml'], yaml: ['Y', 'b-yaml'], xml: ['<>', 'b-xml'], toml: ['T', 'b-yaml'],
sql: ['DB', 'b-json'],
png: ['IM', 'b-img'], jpg: ['IM', 'b-img'], jpeg: ['IM', 'b-img'], gif: ['IM', 'b-img'], svg: ['SV', 'b-img'],
ets: ['ET', 'b-ets'],
so: ['SO', 'b-bin'], dll: ['DL', 'b-bin'], exe: ['EX', 'b-bin']
};
CSS 中每种徽章类型有独立的背景色和文字色(如 b-js 使用 One Dark 风格的黄色 #e5c07b,b-ts 使用蓝色 #61afef),均带透明度实现深色主题融合。
七、拖拽分隔条与三栏布局
7.1 Flex 三栏布局
页面主体采用 Flex 水平排列三栏,中间以分隔条间隔:
<!-- index.html —— 三栏布局骨架 -->
<div class="app">
<aside class="panel-left">...</aside>
<div class="splitter" id="splitLeft" aria-label="拖动调整宽度"></div>
<section class="panel-center">...</section>
<div class="splitter" id="splitRight" aria-label="拖动调整宽度"></div>
<aside class="panel-right">...</aside>
</div>
中间编辑器区域 flex:1 1 0 自动填满剩余宽度,左右面板固定初始宽度(300px / 400px)并通过分隔条拖拽调整。
7.2 分隔条拖拽实现
setupSplitter 函数为每条分隔条绑定 mousedown/mousemove/mouseup 事件链:
// renderer.js —— 分隔条拖拽
function setupSplitter(splitter, panel, side, min, max) {
if (!splitter || !panel) return;
let dragging = false, startX = 0, startW = 0;
splitter.addEventListener('mousedown', (e) => {
dragging = true; startX = e.clientX;
startW = panel.getBoundingClientRect().width;
splitter.classList.add('active');
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const delta = side === 'left' ? e.clientX - startX : startX - e.clientX;
const w = Math.min(max, Math.max(min, startW + delta));
panel.style.width = w + 'px';
panel.style.maxWidth = w + 'px';
panel.style.minWidth = min + 'px';
panel.style.flexBasis = w + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
splitter.classList.remove('active');
document.body.style.userSelect = '';
});
}
左分隔条调整左面板宽度(200px520px),右分隔条调整右面板宽度(280px680px)。拖拽期间禁用文本选择(userSelect:none)防止误选代码。右侧面板的 delta 方向取反(startX - e.clientX),因为向左拖拽时右面板变宽。
7.3 响应式适配
CSS 通过两个断点实现窄屏适配:
/* pi.css —— 响应式断点 */
@media (max-width: 1100px) {
.panel-left { width: 260px; max-width: 260px; flex: 0 0 260px; }
.panel-right { width: 340px; min-width: 280px; max-width: 340px; flex: 0 0 340px; }
}
@media (max-width: 850px) {
.panel-left { width: 220px; max-width: 220px; flex: 0 0 220px; }
.panel-right { width: 300px; min-width: 260px; max-width: 300px; flex: 0 0 300px; }
}
八、鸿蒙稳定性适配
8.1 三防窗口配置
main.js 中采用与其他项目一致的三防配置:
// main.js —— 三防窗口配置
app.disableHardwareAcceleration();
mainWindow = new BrowserWindow({
width: 1240, height: 840, minWidth: 820, minHeight: 600,
backgroundColor: '#11151c', title: '智码编程助手',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, contextIsolation: true, sandbox: false,
webSecurity: true
}
});
与前面几个项目不同,本工程的安全配置更为严格:nodeIntegration 为 false(不允许渲染进程直接访问 Node.js)、contextIsolation 为 true(预加载脚本与渲染进程隔离)、webSecurity 为 true(启用同源策略)。这是 contextBridge 安全模型的前提条件。
8.2 菜单双保险与导航拦截
// main.js —— 菜单双保险 + 导航拦截
Menu.setApplicationMenu(null); // 应用级
mainWindow.setMenu(null); // 窗口级
const contents = mainWindow.webContents;
if (contents.setWindowOpenHandler) contents.setWindowOpenHandler(() => ({ action: 'deny' }));
contents.on('will-navigate', (event) => event.preventDefault());
contents.on('will-attach-webview', (event) => event.preventDefault());
除菜单双保险外,还拦截了三种页面跳转场景:setWindowOpenHandler 阻止 window.open 创建新窗口、will-navigate 阻止页面内导航、will-attach-webview 阻止 webview 标签挂载。
8.3 CSP 内容安全策略
index.html 的 head 中配置了严格的 CSP:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
connect-src 设为 none 意味着页面无法发起任何网络请求(XHR/fetch/WebSocket 全部禁止),所有数据通信只能通过 contextBridge 走 IPC 通道。object-src none 禁止插件,frame-src none 禁止 iframe 嵌入。
8.4 渲染进程崩溃恢复
// main.js —— 崩溃恢复
contents.on('render-process-gone', () => { if (service) service.stop(); });
contents.on('did-start-loading', () => { if (service && service.busy) service.stop(); });
mainWindow.on('closed', () => { if (service) service.stop(); mainWindow = null; });
渲染进程崩溃时立即停止后端服务,防止忙碌状态残留。页面开始加载时如果后端仍在忙碌也执行停止,确保新页面获得干净状态。
8.5 Toast 与状态栏反馈
所有操作的成败通过状态栏和 Toast 双重反馈:
// renderer.js —— Toast 通知
let toastTimer = null;
function showToast(text, ok) {
const t = $('toast'); if (!t) return;
t.textContent = (ok ? '✓ ' : '✗ ') + text;
t.classList.toggle('toast-error', !ok);
t.style.position = 'fixed'; t.style.zIndex = '200';
t.hidden = false;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, 3200);
}
成功操作显示绿色 ✓ 前缀,失败操作显示红色 ✗ 前缀并切换 toast-error 样式。3200 毫秒后自动隐藏。
8.6 关于弹窗(开源声明)
页面右下角状态栏的"关于"链接打开三段式弹窗:
- 开源声明:基于 Electron(MIT 协议)构建,核心 Agent 循环参考 Pi Agent(MIT 开源)二次开发
- 免责声明:所有文件读取、分析与修改均在本地完成,不包含任何网络传输功能
- 许可证:Apache-2.0 完全开源
弹窗通过 hidden 属性切换显隐,pi.css 中 [hidden]{display:none!important} 确保优先级。
九、常见问题与解决方案
Q1:AGC 审核驳回——应用名称与包名不一致
问题现象: 提交 AGC 审核后收到驳回意见,指出应用显示名称与包名或内部配置不一致。
根因分析: AppScope/app.json5 中的 bundleName、app_name,以及三套 EntryAbility_label(base/zh_CN/en_US)未统一。
解决: 确保以下五处名称完全一致:
- AppScope/app.json5 → bundleName: com.wei.zhima
- AppScope/resources/base/element/string.json → app_name: 智码编程助手
- electron/src/main/resources/base/element/string.json → EntryAbility_label: 智码编程助手
- electron/src/main/resources/en_US/element/string.json → EntryAbility_label: 智码编程助手
- electron/src/main/resources/zh_CN/element/string.json → EntryAbility_label: 智码编程助手
教训: 鸿蒙资源覆盖规则是 locale 目录 > base 目录,修改应用名时必须全局 grep 所有 element/string.json 文件,不能只改 base。
Q2:构建签名报错 ERROR: 11014001 key alias not found
问题现象: 在 DevEco Studio 中执行 Build → Build Hap(s),SignHap 阶段报错 ERROR: 11014001,提示签名密钥别名找不到。
根因分析: build-profile.json5 中 signingConfigs.default.material.keyAlias 填写的值与 .p12 签名文件内实际存储的别名不一致。
解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名:
"%DEVECO_JBR%\jbr\bin\keytool" -list -keystore "Zhima.p12" -storepass 你的密码
查看输出中的 Alias Name,然后填入 build-profile.json5:
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "C:/path/to/Zhima.p12",
"keyAlias": "zhima1", // 必须与 p12 内真实别名完全一致
"signAlg": "SHA256withECDSA",
"profile": "C:/path/to/ZhimaRelease.p7b",
"certpath": "C:/path/to/Zhima.cer"
}
}]
Q3:工作区未选择时文件树报错"请先选择项目目录"
问题现象: 应用启动后,左侧文件树区域直接显示错误信息"请先选择项目目录",而非欢迎空态页面。
根因分析: renderer.js 初始化时调用 openWorkspace(‘.’),在 setWorkspace 尚未执行前 state.directory 为 ‘.’,此时 loadFiles 和 refreshOverview 以 ‘.’ 触发 list 请求,后端 Workspace 类检测到工作区未设置直接抛出异常。
解决: 初始化改为 openWorkspace(null),并在各渲染函数中加入 !state.directory 空态守卫:
// renderer.js —— 初始化与空态守卫
guard(async () => {
if (!window.pi) throw new Error('通信桥加载失败');
openWorkspace(null); // 以 null 起步,不触发 list 请求
})();
async function loadFiles() {
const box = $('fileTree'); if (!box) return;
if (!state.directory) { box.replaceChildren(el('p', 'muted', '选择项目后浏览文件')); return; }
// ... 正常加载文件树
}
async function refreshOverview() {
if (!state.directory) {
$('overviewEmpty').hidden = false;
$('overviewContent').hidden = true;
return;
}
// ... 正常扫描项目
}
openWorkspace(null) 将 state.directory 置为 null,各渲染函数检测到 null 后显示欢迎空态,而非向后端发起无效请求。
Q4:左侧面板滚动条过小,文件显示区被压塌
问题现象: 文件数量超出左侧面板高度后,滚动条只出现在 .file-tree 子区域内,显示区被压缩到页面上部约 1/3 的高度。
根因分析: 早期方案给 .file-tree 加 overflow-y:auto + flex:1 1 auto + min-height:0,在 flex column 子项上叠加 min-height:0 虽然能出滚动条,但会让子项高度塌缩到近零,父容器的高度约束传不进来。
解决: 把滚动从子项上移到面板层,让子项恢复自然高度:
/* pi.css —— 左侧面板整体滚动 */
.panel-left {
width: 300px; min-width: 240px; max-width: 300px;
flex: 0 0 300px;
display: flex; flex-direction: column;
overflow-y: auto; /* 整个面板统一滚动 */
}
.explorer-section:first-of-type {
flex: 1 0 auto; /* 填满剩余高度,不收缩 */
min-height: 120px;
}
.file-tree {
flex: 1 0 auto; /* 自然高度,不单独滚动 */
padding: 0 4px;
}
判据:用户要的是"整块面板的大滚动条",应在面板容器加 overflow-y:auto 并让子项恢复自然高度,而不是在子项上叠加 min-height:0。
Q5:文件重命名后标签页路径不同步导致保存失败
问题现象: 在文件树中右键重命名一个已打开的文件后,编辑器标签页仍持有旧路径,点击保存时后端报"文件不存在"。
根因分析: ctxRename 成功后只刷新了文件树,没有同步更新 state.openTabs 中对应标签页的 path 字段,导致后续 write 请求使用旧路径。
解决: 重命名成功后遍历所有打开的标签页,将匹配旧路径的标签页 path 更新为新路径(通过目录拼接新文件名),同步更新 activeTab:
// renderer.js —— 重命名后标签页路径同步
async function ctxRename() {
if (!ctxTarget) return;
const oldName = ctxTarget.name; const entryPath = ctxTarget.path;
hideCtxMenu();
showPromptModal('重命名', oldName, '', true, async (newName) => {
newName = (newName || '').trim();
if (!newName || newName === oldName) return;
try {
await request('rename', { path: entryPath, newName });
status('已重命名:' + oldName + ' → ' + newName);
guard(loadFiles)();
// 同步已打开的标签页路径
const tab = state.openTabs.find((t) => t.path === entryPath);
if (tab) {
const dir = entryPath.replace(/[\\/][^\\/]*$/, '');
tab.path = dir ? dir + '/' + newName : newName;
if (state.activeTab === entryPath) state.activeTab = tab.path;
renderTabs();
}
} catch (e) { status('重命名失败:' + e.message); }
});
}
dir 提取旧路径的目录部分,再拼接新文件名组成新路径。如果重命名的文件恰好是当前活动标签页,同步更新 activeTab。
Q6:修改了代码,但 HAP 安装后没有生效
问题现象: 修改了 renderer.js、lib/tools.cjs 等文件,重新构建 HAP 安装后,应用行为没有任何变化。
根因分析: 鸿蒙壳工程的实际运行文件从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载,而非开发目录。修改后必须手动同步。
解决: 每次修改后执行同步命令:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/PiAgent/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
验证方法: 同步后用文件哈希对比确认一致性。
Q7:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限
问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了受限 ACL 权限,但应用隐私政策中未声明对应用途。
根因分析: Electron 壳工程模板默认声明了大量系统权限,智码编程助手作为本地编程辅助工具不需要位置、相机、麦克风等权限。
解决: 精简权限声明,只保留应用实际使用的 14 项系统权限(INTERNET、GET_NETWORK_INFO、ACCESS_CERT_MANAGER、RUNNING_LOCK、PRINT、PREPARE_APP_TERMINATE、ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST、GYROSCOPE、ACCELEROMETER、SET_ABILITY_INSTANCE_INFO、GET_FILE_ICON、LOCK_WINDOW_CURSOR),移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。
十、总结
本文完整记录了在鸿蒙 PC 平台上适配智码编程助手的全过程。通过 Electron 壳方案,我们实现了一个高安全等级的纯 JavaScript 本地集成开发环境,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| contextBridge | 白名单动作桥接 + CSP connect-src:none + 来源校验 |
| 单通道路由 | pi:request 统一分发 16 种操作 + 错误脱敏 + 互斥保护 |
| Workspace 沙箱 | 五维路径校验 + 敏感目录过滤 + 自研递归删除 + 排他创建 |
| 多标签编辑器 | textarea 透明叠加 + 高亮层同步滚动 + 未保存标记动态切换 |
| 语法高亮 | 20 余种语言正则分词 + 关键字/字符串/数字/注释四类 token |
| 项目概览 | 递归扫描 + 文件统计 + 两级目录树 + 最近修改 + 类型分布条形图 |
| 拖拽三栏布局 | 两条分隔条 + 面板宽度边界约束 + 响应式断点 |
| 鸿蒙稳定 | 三防窗口 + 菜单双保险 + 导航拦截 + 渲染崩溃恢复 + Toast 反馈 |
适配过程中最关键的三个点:
- contextBridge 安全通信模型——渲染进程完全不接触 Node.js API,所有文件操作通过 preload.js 暴露的白名单桥接器发起,主进程 Workspace 类执行五维路径安全校验(空字节注入、敏感目录过滤、路径穿越、应用数据保护、符号链接防御),即使页面被注入恶意脚本攻击面也被压缩到最小
- textarea 透明叠加高亮架构——代码编辑器采用三层叠加方案(底层行号 + 中层高亮 pre + 顶层透明 textarea),textarea 文字颜色设为 transparent 仅保留 caret-color,input 事件触发正则分词重新着色,scroll 事件同步三层滚动位置,在鸿蒙 ArkWeb 引擎上兼容性良好
- 项目概览递归扫描防阻塞——scanProject 采用广度优先遍历,2000 目录上限自动截断,每个目录的 list 请求独立 try-catch 单目录失败不影响整体,最近修改文件按 mtime 降序取前 15 条,Top 10 扩展名条形图使用 16 色循环配色
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版高安全等级本地集成开发环境。
更多推荐



所有评论(0)