Carnac 原本是一款 Windows 平台的按键显示工具,可以在屏幕上实时显示键盘按键操作。本文记录将其迁移到鸿蒙平台适配 Electron 运行时的完整流程,帮助开发者理解跨平台迁移的核心要点。
欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_carnac_electron
一、技术架构分析
1.1 原始架构(Windows C#)
- 技术栈:WPF + C# + Windows API
- 核心逻辑:通过 Windows Hook 监听全局键盘事件,在 WPF 窗口中显示按键
1.2 目标架构(鸿蒙 Electron)
- 技术栈:Electron + HTML/CSS/JavaScript + 鸿蒙 web_engine 模块
- 核心逻辑:通过 Electron 的键盘事件监听,在透明窗口中渲染按键提示
1.3 架构差异
| 对比项 | Windows 原版 | 鸿蒙适配版 |
|---|---|---|
| UI 框架 | WPF | HTML/CSS |
| 事件监听 | Windows Hook | DOM KeyboardEvent |
| 窗口管理 | WPF Window | Electron BrowserWindow |
| 部署方式 | exe 安装包 | HAP 包(鸿蒙应用) |
二、环境准备
2.1 开发环境要求
- 操作系统:Windows 10/11 或 macOS
- 开发工具:DevEco Studio(鸿蒙官方 IDE)
- HarmonyOS SDK:API 15
- Node.js:v24+(Electron 依赖)
2.2 项目结构
bash
ohos_hap/
├── electron-apps/
│ └── carnac/ # Carnac Electron 应用源码
│ ├── main.js # Electron 主进程
│ ├── renderer.js # 渲染进程(按键逻辑)
│ ├── index.html # HTML 结构
│ └── styles/
│ └── carnac.css # 样式文件
├── web_engine/ # 鸿蒙 web_engine 模块
│ └── src/main/resources/
│ └── resfile/resources/app/ # 部署目录
│ ├── main.js
│ ├── renderer.js
│ ├── index.html
│ └── styles/carnac.css
└── build-profile.json5 # 鸿蒙构建配置
三、核心适配流程
3.1 第一步:创建 Electron 窗口
文件:electron-apps/carnac/main.js
bash
function createWindow() {
console.log('Carnac: Creating window...');
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize;
// 窗口宽度设为屏幕的60%,两边各留20%空白
const windowWidth = Math.floor(screenWidth * 0.6);
const windowHeight = 100;
mainWindow = new BrowserWindow({
width: windowWidth,
height: windowHeight,
x: Math.floor((screenWidth - windowWidth) / 2), // 居中
y: screenHeight - windowHeight, // 底部位置
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
resizable: false,
focusable: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false
}
});
console.log('Carnac: Loading index.html from:', path.join(__dirname, 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'index.html'));
mainWindow.setAlwaysOnTop(true, 'screen-saver');
console.log('Carnac: Window created with centered bottom position, width:', windowWidth);
mainWindow.on('closed', () => {
console.log('Carnac: Window closed');
mainWindow = null;
});
mainWindow.webContents.on('did-finish-load', () => {
console.log('Carnac: Page loaded successfully');
// 自动获取焦点
mainWindow.focus();
});
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Carnac: Page failed to load:', errorCode, errorDescription);
});
setupIpcHandlers();
}

关键要点:
- focusable: true 必须设置,否则窗口无法接收键盘事件
- transparent: true 在鸿蒙平台可能不完全生效(平台限制)
- 窗口位置固定在屏幕底部中央,不影响其他操作
3.2 第二步:实现按键监听逻辑
文件:electron-apps/carnac/renderer.js
bash
// ===== 键盘监听 =====
function setupKeyboardListener() {
console.log('Carnac: Setting up keyboard listener');
// 监听整个窗口的键盘事件
window.addEventListener('keydown', (event) => {
console.log('Carnac: keydown event:', event.key, 'ctrl:', event.ctrlKey, 'alt:', event.altKey);
handleKeyDown(event);
});
window.addEventListener('keyup', (event) => {
console.log('Carnac: keyup event:', event.key);
handleKeyUp(event);
});
// 确保窗口可以接收焦点
document.addEventListener('click', () => {
console.log('Carnac: Window clicked, focusing...');
window.focus();
});
console.log('Carnac: Keyboard listeners registered');
}
// 处理按键按下
function handleKeyDown(event) {
console.log('Carnac: handleKeyDown called, key:', event.key, 'passwordMode:', isPasswordMode);
if (isPasswordMode) {
console.log('Carnac: Password mode active, ignoring key');
return;
}
// 只在 event.metaKey 为 true 时才设置 winKeyPressed
if (event.metaKey) {
winKeyPressed = true;
console.log('Carnac: Windows key is being held');
} else {
// 如果当前按键不是 Meta 且 metaKey 为 false,清除状态
winKeyPressed = false;
}
// 单独的修饰键不显示(包括单独按 Shift)
if (MODIFIER_KEYS.includes(event.key)) {
const hasOtherModifier = event.ctrlKey || event.altKey || event.metaKey;
const isOnlyModifier = !hasOtherModifier && !isNonModifierKey(event.key);
if (isOnlyModifier) {
console.log('Carnac: Only modifier key, ignoring');
return;
}
}
if (event.repeat) {
console.log('Carnac: Key repeat, ignoring');
return;
}
const keyCombo = buildKeyCombo(event);
console.log('Carnac: Key combo:', keyCombo);
displayMessage(keyCombo);
}
// 构建按键组合
function buildKeyCombo(event) {
const parts = [];
// 检查是否为单独的修饰键
const isOnlyModifier = MODIFIER_KEYS.includes(event.key);
if (event.ctrlKey && !isOnlyModifier) parts.push('Ctrl');
if (event.altKey && !isOnlyModifier) parts.push('Alt');
if (winKeyPressed && !isOnlyModifier) parts.push('Win');
if (event.ctrlKey || event.altKey) {
if (event.shiftKey && settings.showModifiers) {
parts.push('Shift');
}
// 添加 + 号分隔符
if (parts.length > 0) parts.push('+');
parts.push(getKeyName(event.key));
} else {
const isLetter = isLetterKey(event.key);
const shiftModifies = shiftModifiesKey(event.key);
if (!isLetter && !shiftModifies && event.shiftKey && settings.showModifiers) {
parts.push('Shift');
// 添加 + 号分隔符
parts.push('+');
}
if (event.shiftKey && shiftModifies) {
parts.push(getShiftedKey(event.key));
} else if (isLetter && !event.shiftKey) {
parts.push(event.key.toLowerCase());
} else {
parts.push(getKeyName(event.key));
}
}
return parts;
}
// 键名映射
const KEY_ALIASES = {
'Control': 'Ctrl',
'Meta': 'Win',
' ': 'Space',
'Escape': 'Esc',
'Backspace': 'Backspace',
'Delete': 'Delete',
'Enter': 'Enter'
};
function getKeyName(key) {
if (KEY_ALIASES[key]) {
return KEY_ALIASES[key];
}
if (isLetterKey(key)) {
return key.toUpperCase();
}
return key;
}

关键要点:
- 使用 event.preventDefault() 阻止默认行为
- Win 键状态必须根据 event.metaKey 动态管理,避免状态残留
- 组合键用 + 号分隔(如 Ctrl + C)
3.3 第三步:设计按键提示框 UI
文件:electron-apps/carnac/styles/carnac.css
bash
/* 按键显示区域 - 现代化精美设计 */
.key-display {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(20px);
width: auto;
min-width: 150px;
max-width: 80vw;
pointer-events: none;
z-index: 9999;
padding: 10px 24px;
margin-bottom: 20px;
/* 精美渐变背景 + 毛玻璃 */
background: linear-gradient(135deg, rgba(102, 126, 234, 0.85) 0%, rgba(118, 75, 162, 0.85) 100%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-radius: 30px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 60px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0; /* 默认隐藏 */
overflow: hidden;
display: inline-block;
visibility: hidden; /* 默认不可见 */
}
.messages-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0;
white-space: nowrap;
}
/* 单个按键消息 - 精美排版 */
.message {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
color: #FFFFFF;
font-size: 36px;
font-weight: 700;
letter-spacing: 3px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transition: opacity 0.3s ease, transform 0.3s ease;
white-space: nowrap;
}
.message.fading {
opacity: 0;
transform: translateY(-5px);
}
/* 按键之间的分隔符 */
.message .separator {
color: rgba(255, 255, 255, 0.6);
margin: 0 4px;
font-weight: 400;
}
/* + 号分隔符样式 */
.message {
gap: 6px;
}
/* 按键帽样式 - 纯文本 */
.message .key-cap {
margin: 0;
padding: 0;
background: transparent;
border: none;
color: #FFFFFF;
display: inline-block;
font-size: 36px;
font-weight: 700;
text-align: center;
letter-spacing: 3px;
}
/* 按键文本 */
.message .key-text {
padding: 0;
margin: 0;
vertical-align: middle;
line-height: 1;
color: #FFFFFF;
font-size: 36px;
font-weight: 700;
letter-spacing: 3px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

关键要点:
- 使用 opacity + visibility 实现完全隐藏(而非 display: none)
- backdrop-filter 实现毛玻璃效果(需浏览器支持)
- 渐变色背景提升视觉效果
- pointer-events: none 避免拦截鼠标事件
3.4 第四步:实现动态显隐逻辑
文件:electron-apps/carnac/renderer.js
bash
// ===== 消息显示 =====
function displayMessage(keyCombo) {
const container = document.getElementById('messages-container');
const keyDisplay = document.getElementById('key-display');
// 显示按键区域
keyDisplay.style.opacity = '1';
keyDisplay.style.visibility = 'visible';
keyDisplay.style.transform = 'translateX(-50%) translateY(0)';
// 清除旧消息,只显示最新
container.innerHTML = '';
messages = [];
const messageEl = createMessageElement(keyCombo);
container.appendChild(messageEl);
messages.push(messageEl);
// 设置淡出定时器
setTimeout(() => {
messageEl.classList.add('fading');
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.parentNode.removeChild(messageEl);
messages = [];
}
// 如果没有消息了,完全隐藏显示区域
if (messages.length === 0) {
keyDisplay.style.opacity = '0';
keyDisplay.style.visibility = 'hidden';
keyDisplay.style.transform = 'translateX(-50%) translateY(20px)';
}
}, 300);
}, settings.fadeDelay);
}

关键要点:
- 每次只显示最新按键,不累积历史
- 使用 CSS transition 实现平滑动画
- 2秒后自动淡出并完全隐藏
四、部署到鸿蒙平台效果展示
4.1 文件同步
将 Electron 应用文件复制到鸿蒙 web_engine 模块的部署目录:
bash
# 使用 PowerShell 同步文件
Copy-Item "electron-apps\carnac\main.js" `
-Destination "web_engine\src\main\resources\resfile\resources\app\main.js" `
-Force
Copy-Item "electron-apps\carnac\renderer.js" `
-Destination "web_engine\src\main\resources\resfile\resources\app\renderer.js" `
-Force
Copy-Item "electron-apps\carnac\index.html" `
-Destination "web_engine\src\main\resources\resfile\resources\app\index.html" `
-Force
Copy-Item "electron-apps\carnac\styles\carnac.css" `
-Destination "web_engine\src\main\resources\resfile\resources\app\styles\carnac.css" `
-Force
4.2 构建 HAP 包
在 DevEco Studio 中:
- 打开项目根目录
- 点击 Build > Build Hap(s)/APP(s)
- 选择 Build Hap(s)
- 等待构建完成

4.3 真机测试
- 连接鸿蒙设备(或启动模拟器)
- 点击 Run > Run 'entry'
- 安装完成后,应用会自动启动
- 在屏幕底部中央会看到按键提示框




五、常见问题 FAQ
Q1:按键不显示怎么办?
问题现象:按键盘没有任何反应
根本原因:窗口没有获取焦点,无法接收键盘事件
解决方案:
bash
// main.js 第 65-69 行
mainWindow.webContents.on('did-finish-load', () => {
console.log('Carnac: Page loaded successfully');
// 自动获取焦点(必须,否则无法接收键盘事件)
mainWindow.focus();
});
确保 BrowserWindow 配置中设置了 focusable: true(第 46 行)。
Q2:窗口背景为什么无法完全透明?
问题现象:即使设置了 transparent: true,窗口仍有灰色/白色背景
根本原因:鸿蒙平台的 Electron 实现对透明窗口支持有限,这是平台技术限制
实际情况:
- transparent: true 在 Windows/macOS 上可以完全透明
- 鸿蒙 WebView 渲染机制需要一个基础背景色
- 无法通过代码完全消除
最佳方案:
- 接受底部窄条窗口的设计(宽度 60% 屏幕,高度 100px)
- 通过精美的按键提示框转移视觉焦点
- 窗口背景不会影响核心功能使用
Q3:为什么按完 Win 键后,后续按键都显示 Win+A、Win+B?
问题现象:按一次 Win 键后,再按字母 A、B、C 都会显示 Win + A、Win + B、Win + C
根本原因:winKeyPressed 状态没有被正确清除,导致状态残留
解决方案:根据 event.metaKey 动态管理 Win 键状态
bash
// renderer.js 第 221-228 行
// 处理按键按下
function handleKeyDown(event) {
// ...
// 只在 event.metaKey 为 true 时才设置 winKeyPressed
if (event.metaKey) {
winKeyPressed = true;
console.log('Carnac: Windows key is being held');
} else {
// 如果当前按键不是 Meta 且 metaKey 为 false,清除状态
winKeyPressed = false;
}
// ...
}
关键点:
- event.metaKey === true 表示 Win 键正在被按住
- event.metaKey === false 表示 Win 键已释放,必须立即清除状态
- 不能只在 event.key === 'Meta' 时设置,需要在每次按键时检查
Q4:按键提示框为什么不消失?
问题现象:按键后提示框一直显示,不会自动隐藏
根本原因:没有设置正确的隐藏逻辑或定时器配置错误
解决方案:使用 opacity + visibility 双重控制显隐
bash
// renderer.js 第 345-379 行
function displayMessage(keyCombo) {
const container = document.getElementById('messages-container');
const keyDisplay = document.getElementById('key-display');
// 显示按键区域
keyDisplay.style.opacity = '1';
keyDisplay.style.visibility = 'visible';
keyDisplay.style.transform = 'translateX(-50%) translateY(0)';
// 清除旧消息,只显示最新
container.innerHTML = '';
messages = [];
const messageEl = createMessageElement(keyCombo);
container.appendChild(messageEl);
messages.push(messageEl);
// 设置淡出定时器
setTimeout(() => {
messageEl.classList.add('fading');
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.parentNode.removeChild(messageEl);
messages = [];
}
// 如果没有消息了,完全隐藏显示区域
if (messages.length === 0) {
keyDisplay.style.opacity = '0';
keyDisplay.style.visibility = 'hidden';
keyDisplay.style.transform = 'translateX(-50%) translateY(20px)';
}
}, 300);
}, settings.fadeDelay); // 默认 2000ms
}
关键点:
- opacity: 0 让元素不可见
- visibility: hidden 让元素不响应交互
- 两者结合实现完全隐藏
- settings.fadeDelay 控制显示时长(默认 2 秒)
Q5:为什么组合键中间没有 + 号分隔符
问题现象:按 Ctrl + C 显示为 Ctrl C,缺少 + 号
根本原因:buildKeyCombo 函数中没有添加 + 号分隔符逻辑
解决方案:在修饰键和主键之间插入 + 号
bash
// renderer.js 第 268-306 行
function buildKeyCombo(event) {
const parts = [];
// 检查是否为单独的修饰键
const isOnlyModifier = MODIFIER_KEYS.includes(event.key);
if (event.ctrlKey && !isOnlyModifier) parts.push('Ctrl');
if (event.altKey && !isOnlyModifier) parts.push('Alt');
if (winKeyPressed && !isOnlyModifier) parts.push('Win');
if (event.ctrlKey || event.altKey) {
if (event.shiftKey && settings.showModifiers) {
parts.push('Shift');
}
// 添加 + 号分隔符
if (parts.length > 0) parts.push('+');
parts.push(getKeyName(event.key));
} else {
const isLetter = isLetterKey(event.key);
const shiftModifies = shiftModifiesKey(event.key);
if (!isLetter && !shiftModifies && event.shiftKey && settings.showModifiers) {
parts.push('Shift');
// 添加 + 号分隔符
parts.push('+');
}
if (event.shiftKey && shiftModifies) {
parts.push(getShiftedKey(event.key));
} else if (isLetter && !event.shiftKey) {
parts.push(event.key.toLowerCase());
} else {
parts.push(getKeyName(event.key));
}
}
return parts;
}
效果:
- Ctrl + C 显示为 Ctrl + C
- Shift + A 显示为 Shift + A
- Ctrl + Shift + D 显示为 Ctrl + Shift + D
Q6:连续按键时消息向上叠加怎么办?
问题现象:连续按 z x c v,按键提示框会向上堆叠显示
根本原因:使用 flex-direction: column + insertBefore 导致垂直堆叠
解决方案:
- 改为水平排列
- 每次只显示最新按键,清除旧消息
bash
// renderer.js 第 354-356 行
// 清除旧消息,只显示最新
container.innerHTML = '';
messages = [];
bash
/* carnac.css 第 62-69 行 */
.messages-container {
display: flex;
flex-direction: row; /* 水平排列 */
align-items: center;
justify-content: center;
gap: 0;
white-space: nowrap;
}
Q7:如何同步文件到鸿蒙项目?
问题现象:修改了 electron-apps/carnac/ 下的文件,但构建后没有生效
根本原因:文件没有同步到鸿蒙 web_engine 模块的部署目录
解决方案:使用 PowerShell 脚本同步文件
bash
# 同步 main.js
Copy-Item "electron-apps\carnac\main.js" `
-Destination "web_engine\src\main\resources\resfile\resources\app\main.js" `
-Force
# 同步 renderer.js
Copy-Item "electron-apps\carnac\renderer.js" `
-Destination "web_engine\src\main\resources\resfile\resources\app\renderer.js" `
-Force
# 同步 index.html
Copy-Item "electron-apps\carnac\index.html" `
-Destination "web_engine\src\main\resources\resfile\resources\app\index.html" `
-Force
# 同步 carnac.css
Copy-Item "electron-apps\carnac\styles\carnac.css" `
-Destination "web_engine\src\main\resources\resfile\resources\app\styles\carnac.css" `
-Force
部署路径:web_engine/src/main/resources/resfile/resources/app/
注意:每次修改代码后都需要同步,否则构建的 HAP 包不会包含最新代码。
Q8:为什么单独按 Shift/Ctrl/Alt 不显示?
问题现象:单独按下 Shift、Ctrl、Alt 键时,按键提示框不显示
根本原因:单独的修饰键被过滤掉了(设计如此)
代码逻辑:
bash
// renderer.js 第 231-239 行
// 单独的修饰键不显示(包括单独按 Shift)
if (MODIFIER_KEYS.includes(event.key)) {
const hasOtherModifier = event.ctrlKey || event.altKey || event.metaKey;
const isOnlyModifier = !hasOtherModifier && !isNonModifierKey(event.key);
if (isOnlyModifier) {
console.log('Carnac: Only modifier key, ignoring');
return; // 忽略单独的修饰键
}
}
设计原因:
- 单独按修饰键通常没有实际意义
- 避免误触导致频繁显示
- 只在组合键中显示修饰键(如 Ctrl + C)
如果需要显示:可以注释掉这段逻辑,但不推荐。