鸿蒙PC平台 Carnac 按键显示适配实战:从 Windows 到 HarmonyOS 的 Electron 迁移指南

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 中:

  1. 打开项目根目录
  2. 点击 Build > Build Hap(s)/APP(s)
  3. 选择 Build Hap(s)
  4. 等待构建完成

4.3 真机测试

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

五、常见问题 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 导致垂直堆叠

解决方案:

  1. 改为水平排列
  2. 每次只显示最新按键,清除旧消息
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)

如果需要显示:可以注释掉这段逻辑,但不推荐。

相关推荐
拾贰_C1 小时前
【mysql | windows | installation】 MySQL5.安装
数据库·windows·mysql
●VON1 小时前
AtomGit Flutter鸿蒙客户端:仓库详情页
flutter·华为·跨平台·harmonyos·鸿蒙
小雨青年2 小时前
鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 19:设置页在 Pura X Max 上改成分组布局
华为·harmonyos
浮芷.2 小时前
鸿蒙PC端 TTS 并发调用问题详解:资源竞争与队列管理
算法·华为·开源·harmonyos·鸿蒙·鸿蒙系统
小雨下雨的雨2 小时前
基于鸿蒙PC Electron框架技术完成的表单验证技术详解
前端·javascript·华为·electron·前端框架·鸿蒙
提子拌饭1332 小时前
饮料含糖量查询应用 - 鸿蒙PC用Electron框架完整实现
前端·javascript·华为·electron·前端框架·鸿蒙
nashane2 小时前
HarmonyOS 6学习:句柄泄漏(Fd Leak)从“崩溃现场”到“代码行”的精准狙击指南
学习·华为·音视频·harmonyos
坚果派·白晓明2 小时前
[鸿蒙PC三方库移植适配] 使用 AtomCode + Skills 自动完成Protobuf鸿蒙化适配
c语言·c++·华为·harmonyos
世人万千丶3 小时前
鸿蒙PC异常解决:Install Failed: error: failed to install bundle.
服务器·华为·开源·harmonyos·鸿蒙