Files
voidraft/internal/common/hotkey
..
2025-11-06 22:42:44 +08:00
2025-11-06 22:42:44 +08:00
2025-11-08 17:05:31 +08:00
2025-11-08 17:05:31 +08:00
2025-11-06 22:42:44 +08:00
2025-11-06 22:42:44 +08:00
2025-11-06 22:42:44 +08:00
2025-11-06 22:42:44 +08:00

Hotkey - 跨平台全局热键库

跨平台 Go 语言全局热键库,支持 Windows、Linux (X11) 和 macOS 操作系统。

特性

  • 跨平台支持Windows、Linux (X11)、macOS 统一 API
  • 线程安全:所有公共 API 使用互斥锁保护
  • 标准化错误:提供统一的错误类型,便于错误处理
  • 资源管理支持手动和自动finalizer资源清理
  • 独立实现:除系统库外无第三方 Go 依赖
  • 状态查询:提供 IsRegistered()IsClosed() 方法

📦 安装

go get -u voidraft/internal/common/hotkey

平台特定依赖

Linux

需要安装 X11 开发库:

# Debian/Ubuntu
sudo apt install -y libx11-dev

# CentOS/RHEL
sudo yum install -y libX11-devel

# Arch Linux
sudo pacman -S libx11

无界面环境(云服务器等)

# 安装虚拟显示服务器
sudo apt install -y xvfb

# 启动虚拟显示
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0

macOS

GUI 应用Wails、Fyne、Cocoa 等):框架管理主事件循环,直接使用即可。

CLI 应用:需要使用 darwin.Init() 启动 NSApplication 主事件循环,参见下文"macOS CLI 应用示例"。

Windows

无额外依赖,开箱即用。

📖 使用指南

基本用法

package main

import (
    "fmt"
    "voidraft/internal/common/hotkey"
)

func main() {
    // 创建热键Ctrl+Shift+S
    hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
    
    // 注册热键
    if err := hk.Register(); err != nil {
        panic(err)
    }
    defer hk.Close() // 确保资源清理
    
    fmt.Println("热键已注册,按 Ctrl+Shift+S 触发...")
    
    // 监听热键事件
    for {
        select {
        case <-hk.Keydown():
            fmt.Println("热键按下!")
        case <-hk.Keyup():
            fmt.Println("热键释放!")
        }
    }
}

完整示例:带错误处理

package main

import (
    "errors"
    "fmt"
    "log"
    "os"
    "os/signal"
    "syscall"
    "voidraft/internal/common/hotkey"
)

func main() {
    // 创建热键
    hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModAlt}, hotkey.KeyQ)
    
    // 注册热键,处理可能的错误
    if err := hk.Register(); err != nil {
        switch {
        case errors.Is(err, hotkey.ErrHotkeyConflict):
            log.Fatal("热键冲突:该组合键已被其他程序占用")
        case errors.Is(err, hotkey.ErrPlatformUnavailable):
            log.Fatal("平台不支持:", err)
        case errors.Is(err, hotkey.ErrAlreadyRegistered):
            log.Fatal("热键已经注册")
        default:
            log.Fatal("注册热键失败:", err)
        }
    }
    defer hk.Close()
    
    fmt.Println("热键 Ctrl+Alt+Q 已注册")
    fmt.Println("按下热键触发,或按 Ctrl+C 退出...")
    
    // 优雅退出
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    // 事件循环
    for {
        select {
        case <-hk.Keydown():
            fmt.Println("[事件] 热键按下")
            // 执行你的业务逻辑
            
        case <-hk.Keyup():
            fmt.Println("[事件] 热键释放")
            
        case <-sigChan:
            fmt.Println("\n正在退出...")
            return
        }
    }
}

防抖处理(应用层)

如果热键按住会持续触发,建议在应用层添加防抖:

package main

import (
    "fmt"
    "log"
    "time"
    "voidraft/internal/common/hotkey"
)

func main() {
    hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.KeyD)
    if err := hk.Register(); err != nil {
        log.Fatal(err)
    }
    defer hk.Close()
    
    // 防抖参数
    var lastTrigger time.Time
    debounceInterval := 800 * time.Millisecond // 推荐 800ms
    
    for {
        select {
        case <-hk.Keydown():
            now := time.Now()
            // 检查距离上次触发的时间
            if !lastTrigger.IsZero() && now.Sub(lastTrigger) < debounceInterval {
                fmt.Println("触发被忽略(防抖)")
                continue
            }
            lastTrigger = now
            
            fmt.Println("热键触发!")
            // 执行你的业务逻辑
        }
    }
}

动态修改热键

// 注销旧热键
if err := hk.Unregister(); err != nil {
    log.Printf("注销失败:%v", err)
}

// 修改热键组合
hk = hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.KeyF1)

// 重新注册
if err := hk.Register(); err != nil {
    log.Printf("注册失败:%v", err)
}

// 重要:重新获取通道引用!
keydownChan := hk.Keydown()
keyupChan := hk.Keyup()

状态检查

// 检查热键是否已注册
if hk.IsRegistered() {
    fmt.Println("热键已注册")
}

// 检查热键是否已关闭
if hk.IsClosed() {
    fmt.Println("热键已关闭,无法再使用")
}

资源管理最佳实践

func registerHotkey() error {
    hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.KeyH)
    
    // 使用 defer 确保资源释放
    defer hk.Close()
    
    if err := hk.Register(); err != nil {
        return err
    }
    
    // ... 使用热键 ...
    
    return nil
}

// Close() 是幂等的,多次调用是安全的
hk.Close()
hk.Close() // 不会 panic

🔑 支持的修饰键

所有平台通用

hotkey.ModCtrl   // Ctrl 键
hotkey.ModShift  // Shift 键
hotkey.ModAlt    // Alt 键Linux: 通常映射到 Mod1

Linux 额外支持

hotkey.Mod1  // 通常是 Alt
hotkey.Mod2  // 通常是 Num Lock
hotkey.Mod3  // (较少使用)
hotkey.Mod4  // 通常是 Super/Windows 键
hotkey.Mod5  // (较少使用)

组合使用

// Ctrl+Shift+Alt+S
hk := hotkey.New(
    []hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift, hotkey.ModAlt},
    hotkey.KeyS,
)

⌨️ 支持的按键

字母键

hotkey.KeyA - hotkey.KeyZ  // A-Z

数字键

hotkey.Key0 - hotkey.Key9  // 0-9

功能键

hotkey.KeyF1 - hotkey.KeyF20  // F1-F20

特殊键

hotkey.KeySpace    // 空格
hotkey.KeyReturn   // 回车
hotkey.KeyEscape   // ESC
hotkey.KeyDelete   // Delete
hotkey.KeyTab      // Tab
hotkey.KeyLeft     // 左箭头
hotkey.KeyRight    // 右箭头
hotkey.KeyUp       // 上箭头
hotkey.KeyDown     // 下箭头

自定义键码

如果需要的键未预定义,可以直接使用键码:

// 使用自定义键码 0x15
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.Key(0x15))

⚠️ 错误类型

// 检查特定错误类型
if errors.Is(err, hotkey.ErrAlreadyRegistered) {
    // 热键已经注册
}

if errors.Is(err, hotkey.ErrNotRegistered) {
    // 热键未注册
}

if errors.Is(err, hotkey.ErrClosed) {
    // 热键已关闭,无法再使用
}

if errors.Is(err, hotkey.ErrHotkeyConflict) {
    // 热键冲突,已被其他程序占用
}

if errors.Is(err, hotkey.ErrPlatformUnavailable) {
    // 平台不可用(如 Linux 无 X11
}

if errors.Is(err, hotkey.ErrFailedToRegister) {
    // 注册失败(其他原因)
}

if errors.Is(err, hotkey.ErrFailedToUnregister) {
    // 注销失败
}

🎯 平台特定注意事项

Linux

1. AutoRepeat 行为

Linux 的 X11 会在按键持续按下时重复发送 KeyPress 事件。如果你的应用对此敏感,需要做防抖处理:

var lastTrigger time.Time
debounceInterval := 500 * time.Millisecond

for {
    select {
    case <-hk.Keydown():
        now := time.Now()
        if now.Sub(lastTrigger) < debounceInterval {
            continue // 忽略重复触发
        }
        lastTrigger = now
        
        // 处理热键事件
        fmt.Println("热键触发!")
    }
}

2. 键位映射差异

不同的 Linux 发行版和桌面环境可能有不同的键位映射。建议:

  • 使用标准的 ModCtrlModShiftModAlt
  • 避免依赖 Mod2Mod3Mod5(映射不一致)
  • Mod4 通常是 Super/Windows 键,但也可能不同

3. Wayland 支持

当前版本仅支持 X11。在 Wayland 环境下:

  • 需要运行在 XWayland 兼容层
  • 或设置 GDK_BACKEND=x11 环境变量
  • 原生 Wayland 支持正在开发中

4. Display 连接复用

本库已优化 Linux 实现,在热键注册期间保持 X11 Display 连接:

注册时:    XOpenDisplay → XGrabKey → 保持连接
事件循环:  使用相同连接 → XNextEvent
注销时:    XUngrabKey → XCloseDisplay

这大幅降低了资源开销和延迟。

Windows

1. 热键按下事件

Windows 使用 RegisterHotKey API 注册系统级热键,通过 WM_HOTKEY 消息接收按键事件。

实现细节

  • 使用 PeekMessageW (Unicode) 轮询消息队列
  • 10ms 轮询间隔CPU 占用约 0.3-0.5%
  • isKeyDown 状态标志防止重复触发
  • 按下事件延迟通常 < 10ms

2. 热键释放事件

Windows RegisterHotKey API 不提供键释放通知,本库使用 GetAsyncKeyState 轮询检测:

// 检测键释放(每 10ms 检查一次)
if isKeyDown && GetAsyncKeyState(key) == 0 {
    keyupIn <- struct{}{}
    isKeyDown = false
}

特性

  • 释放检测延迟:通常 10-20ms
  • 仅在按键按下后激活检测
  • 依赖于 GetAsyncKeyState 的精度

3. 持续按住行为

Windows 在持续按住热键时会重复发送 WM_HOTKEY 消息。本库通过 isKeyDown 标志防止同一次按住重复触发 Keydown 事件。

如果需要防止快速连续按键,建议在应用层添加防抖:

var lastTrigger time.Time
debounceInterval := 100 * time.Millisecond

for {
    <-hk.Keydown()
    now := time.Now()
    if now.Sub(lastTrigger) < debounceInterval {
        continue  // 忽略
    }
    lastTrigger = now
    // 处理事件...
}

4. 系统保留热键

某些热键被 Windows 系统保留,无法注册:

  • Win+L:锁定屏幕
  • Ctrl+Alt+Del:安全选项
  • Alt+Tab:切换窗口
  • Alt+F4:关闭窗口
  • Win+D:显示桌面

尝试注册这些热键会返回 ErrFailedToRegister

5. 热键冲突

如果热键已被其他应用注册,RegisterHotKey 会失败。常见冲突来源:

  • 游戏快捷键
  • 输入法快捷键
  • 快捷键管理工具AutoHotkey 等)
  • 其他全局热键应用

返回错误为 ErrFailedToRegisterWindows 不区分冲突和其他失败)。

6. 线程模型

  • 热键注册和消息循环运行在同一个 OS 线程上
  • 使用 runtime.LockOSThread() 确保线程亲和性
  • 不要求是主线程(与 macOS 不同)
  • ph.funcs channel 用于在事件循环线程中执行注册/注销操作

macOS

1. 主事件循环要求

macOS 的 Carbon 事件 API 通过 GCDGrand Central Dispatch调度到主队列执行。

GUI 应用Wails、Cocoa 等)

  • 框架已自动管理主事件循环
  • 热键功能开箱即用,无需额外配置

纯 CLI 应用

  • ⚠️ 需要手动启动 macOS 运行循环
  • 参见下文"macOS 纯 CLI 应用示例"

2. 权限问题

macOS 可能需要辅助功能权限。如果热键无法注册,请检查:

系统偏好设置 → 安全性与隐私 → 隐私 → 辅助功能

将你的应用添加到允许列表。

3. Carbon vs Cocoa

当前实现使用 Carbon API稳定且兼容性好。未来版本可能会迁移到更现代的 Cocoa API。

4. macOS 纯 CLI 应用示例

如果你的应用不是 GUI 应用,需要启动主事件循环。

最简单的方法:使用 darwin.Init()(推荐)

//go:build darwin

package main

import (
    "fmt"
    "voidraft/internal/common/hotkey"
    "voidraft/internal/common/hotkey/darwin"
)

func main() {
    // 使用 darwin.Init 启动主事件循环
    darwin.Init(run)
}

func run() {
    hk := hotkey.New([]hotkey.Modifier{hotkey.ModCmd, hotkey.ModShift}, hotkey.KeyA)
    if err := hk.Register(); err != nil {
        fmt.Printf("注册失败: %v\n", err)
        return
    }
    defer hk.Close()
    
    fmt.Println("热键已注册: Cmd+Shift+A")
    
    for {
        <-hk.Keydown()
        fmt.Println("热键触发!")
    }
}

高级用法:使用 darwin.Call() 在主线程执行操作

darwin.Init(func() {
    hk := hotkey.New(...)
    hk.Register()
    
    for {
        <-hk.Keydown()
        // 在主线程执行 macOS API 调用
        darwin.Call(func() {
            // 例如调用 Cocoa/AppKit API
            fmt.Println("这段代码在主线程执行")
        })
    }
})

注意

  • GUI 应用无需调用 darwin.Init(),框架已处理
  • darwin.Call() 用于需要主线程的特定 macOS API
  • 热键注册本身已通过 dispatch_get_main_queue() 自动调度到主线程

🔬 架构设计

目录结构

internal/common/hotkey/
├── hotkey.go              # 统一的公共 API
├── hotkey_windows.go      # Windows 平台适配器
├── hotkey_darwin.go       # macOS 平台适配器
├── hotkey_linux.go        # Linux 平台适配器
├── hotkey_nocgo.go        # 非 CGO 平台占位符
├── windows/
│   ├── hotkey.go          # Windows 核心实现
│   └── mainthread.go      # Windows 线程管理
├── darwin/
│   ├── hotkey.go          # macOS 核心实现
│   ├── hotkey.m           # Objective-C/Carbon 代码
│   └── mainthread.go      # macOS 线程管理
└── linux/
    ├── hotkey.go          # Linux 核心实现
    ├── hotkey.c           # X11 C 代码
    └── mainthread.go      # Linux 线程管理

设计原则

  1. 平台隔离:每个平台的实现完全独立,通过构建标签分离
  2. 统一接口:所有平台提供相同的 Go API
  3. 资源安全:自动资源管理,防止泄漏
  4. 并发安全:所有公共方法都是线程安全的
  5. 错误透明:标准化错误类型,便于处理

事件流程

用户按键
    ↓
操作系统捕获
    ↓
平台特定 APIWin32/X11/Carbon
    ↓
C/Objective-C 回调
    ↓
Go channel类型转换
    ↓
用户应用代码

并发模型

主 Goroutine              事件 Goroutine            转换 Goroutine
    │                          │                          │
Register() ────启动──────→ eventLoop()                    │
    │                          │                          │
    │                      等待 OS 事件                    │
    │                          │                          │
    │                          ├────发送────→ 类型转换      │
    │                          │              │            │
    │                          │              └─→ Keydown()/Keyup()
    │                          │                          │
Unregister() ──停止信号──→ 退出循环                       │
    │                          │                          │
    └──────等待清理─────────────┴──────────────────────────┘

📊 性能特性

资源占用

  • 内存:每个热键约 1-2 KB包括 goroutines、channels、CGO handles
  • Goroutines:每个热键 3 个
    • 1 个事件循环 goroutine
    • 2 个通道转换 goroutine (interface{} → Event)
  • CPU
    • Windows10ms 轮询,约 0.3-0.5% CPU单核空闲时
    • Linux:事件驱动 (XNextEvent 阻塞),几乎无 CPU 占用
    • macOS:事件驱动 (GCD 调度),几乎无 CPU 占用
  • 线程
    • 每个热键 1 个 OS 线程(通过 runtime.LockOSThread() 锁定)

延迟

按下事件 (Keydown)

  • Windows: < 10ms取决于轮询间隔
  • Linux: < 10msX11 事件延迟)
  • macOS: < 5msCarbon 事件延迟)

释放事件 (Keyup)

  • Windows: 10-20msGetAsyncKeyState 轮询检测)
  • Linux: < 15msX11 KeyRelease 事件)
  • macOS: < 10msCarbon 事件延迟)

使用建议

  1. 资源管理

    • 使用 defer hk.Close() 确保资源释放
    • 不需要时及时调用 Unregister()Close()
    • 避免频繁创建/销毁热键对象
  2. 事件处理

    • 热键事件处理应快速返回,避免阻塞 channel
    • 复杂逻辑应在新 goroutine 中处理
    • 考虑应用层防抖(特别是 Linux AutoRepeat
  3. 错误处理

    • 始终检查 Register() 返回的错误
    • 使用 errors.Is() 判断错误类型
    • 处理热键冲突场景(提供备用方案或用户提示)
  4. 平台差异

    • Windows Keyup 事件有轻微延迟(正常现象)
    • Linux 可能需要防抖处理 AutoRepeat
    • macOS CLI 应用需要启动主事件循环

🐛 故障排查

Linux: "Failed to initialize the X11 display"

问题:无法连接到 X11 显示服务器

解决方案

# 检查 DISPLAY 环境变量
echo $DISPLAY

# 如果为空,设置它
export DISPLAY=:0

# 或使用虚拟显示
Xvfb :99 -screen 0 1024x768x24 &
export DISPLAY=:99

macOS: 热键不触发

问题:注册成功但热键无响应

解决方案

  1. 检查辅助功能权限(见上文)
  2. 如果是纯 CLI 应用,确保启动了主运行循环(见上文示例)
  3. 检查其他应用是否占用了该热键

Windows: ErrFailedToRegister

问题:热键注册失败

可能原因

  1. 热键已被其他应用占用AutoHotkey、游戏、输入法等
  2. 尝试注册系统保留热键Win+L、Ctrl+Alt+Del 等)
  3. 热键 ID 冲突(极少见)

解决方案

  1. 检查任务管理器,关闭可能冲突的应用
  2. 使用不同的热键组合
  3. 在应用中提供热键自定义功能,让用户选择可用组合
  4. 提供友好的错误提示,说明热键被占用

调试方法

if err := hk.Register(); err != nil {
    if errors.Is(err, hotkey.ErrFailedToRegister) {
        log.Printf("热键注册失败: %v", err)
        log.Printf("提示:该热键可能已被其他应用使用")
        // 尝试备用热键...
    }
}

Windows: Keyup 事件延迟

问题:释放事件比预期晚 10-20ms

原因Windows API 限制,RegisterHotKey 不提供释放通知,需要轮询检测。

这是正常行为

  • 10ms 轮询间隔导致的固有延迟
  • 对大多数应用场景影响很小
  • 如果需要精确的释放时机,考虑使用底层键盘钩子(复杂度更高)

所有平台: Keyup 事件丢失

问题:只收到 Keydown没有 Keyup

可能原因

  1. 在接收 Keyup 前调用了 Unregister()
  2. 通道缓冲区满(不太可能,使用了无缓冲通道)
  3. Windows 上正常(有轻微延迟)

解决方案

// 确保在处理完事件后再注销
for {
    select {
    case <-hk.Keydown():
        // 处理...
    case <-hk.Keyup():
        // 处理...
        // 现在可以安全注销
        hk.Unregister()
        return
    }
}

🤝 贡献

欢迎贡献!请遵循以下原则:

  1. 保持简洁:不要过度优化
  2. 平台一致:确保 API 在所有平台表现一致
  3. 测试充分:在所有支持的平台测试
  4. 文档完善:更新相关文档

📄 许可证

本项目是 Voidraft 的一部分,遵循项目主许可证。

🙏 致谢

本库的设计和实现参考了多个开源项目的经验:

  • golang.design/x/hotkey - 基础架构设计
  • github.com/robotn/gohook - 跨平台事件处理思路

特别感谢所有为跨平台 Go 开发做出贡献的开发者们!


如有问题或建议,欢迎提交 Issue 🚀