Optimize hotkey service

This commit is contained in:
2025-11-06 22:42:44 +08:00
parent e0179b5838
commit 551e7e2cfd
26 changed files with 2917 additions and 1116 deletions

View File

@@ -0,0 +1,817 @@
# Hotkey - 跨平台全局热键库
跨平台 Go 语言全局热键库,支持 Windows、Linux (X11) 和 macOS 操作系统。
## ✨ 特性
- **跨平台支持**Windows、Linux (X11)、macOS 统一 API
- **线程安全**:所有公共 API 使用互斥锁保护
- **标准化错误**:提供统一的错误类型,便于错误处理
- **资源管理**支持手动和自动finalizer资源清理
- **独立实现**:除系统库外无第三方 Go 依赖
- **状态查询**:提供 `IsRegistered()``IsClosed()` 方法
## 📦 安装
```bash
go get -u voidraft/internal/common/hotkey
```
### 平台特定依赖
#### Linux
需要安装 X11 开发库:
```bash
# Debian/Ubuntu
sudo apt install -y libx11-dev
# CentOS/RHEL
sudo yum install -y libX11-devel
# Arch Linux
sudo pacman -S libx11
```
**无界面环境(云服务器等)**
```bash
# 安装虚拟显示服务器
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
无额外依赖,开箱即用。
## 📖 使用指南
### 基本用法
```go
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("热键释放!")
}
}
}
```
### 完整示例:带错误处理
```go
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
}
}
}
```
### 防抖处理(应用层)
如果热键按住会持续触发,建议在应用层添加防抖:
```go
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("热键触发!")
// 执行你的业务逻辑
}
}
}
```
### 动态修改热键
```go
// 注销旧热键
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()
```
### 状态检查
```go
// 检查热键是否已注册
if hk.IsRegistered() {
fmt.Println("热键已注册")
}
// 检查热键是否已关闭
if hk.IsClosed() {
fmt.Println("热键已关闭,无法再使用")
}
```
### 资源管理最佳实践
```go
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
```
## 🔑 支持的修饰键
### 所有平台通用
```go
hotkey.ModCtrl // Ctrl 键
hotkey.ModShift // Shift 键
hotkey.ModAlt // Alt 键Linux: 通常映射到 Mod1
```
### Linux 额外支持
```go
hotkey.Mod1 // 通常是 Alt
hotkey.Mod2 // 通常是 Num Lock
hotkey.Mod3 // (较少使用)
hotkey.Mod4 // 通常是 Super/Windows 键
hotkey.Mod5 // (较少使用)
```
### 组合使用
```go
// Ctrl+Shift+Alt+S
hk := hotkey.New(
[]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift, hotkey.ModAlt},
hotkey.KeyS,
)
```
## ⌨️ 支持的按键
### 字母键
```go
hotkey.KeyA - hotkey.KeyZ // A-Z
```
### 数字键
```go
hotkey.Key0 - hotkey.Key9 // 0-9
```
### 功能键
```go
hotkey.KeyF1 - hotkey.KeyF20 // F1-F20
```
### 特殊键
```go
hotkey.KeySpace // 空格
hotkey.KeyReturn // 回车
hotkey.KeyEscape // ESC
hotkey.KeyDelete // Delete
hotkey.KeyTab // Tab
hotkey.KeyLeft // 左箭头
hotkey.KeyRight // 右箭头
hotkey.KeyUp // 上箭头
hotkey.KeyDown // 下箭头
```
### 自定义键码
如果需要的键未预定义,可以直接使用键码:
```go
// 使用自定义键码 0x15
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl}, hotkey.Key(0x15))
```
## ⚠️ 错误类型
```go
// 检查特定错误类型
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` 事件。如果你的应用对此敏感,需要做防抖处理:
```go
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 发行版和桌面环境可能有不同的键位映射。建议:
- 使用标准的 `ModCtrl``ModShift``ModAlt`
- 避免依赖 `Mod2``Mod3``Mod5`(映射不一致)
- `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` 轮询检测:
```go
// 检测键释放(每 10ms 检查一次)
if isKeyDown && GetAsyncKeyState(key) == 0 {
keyupIn <- struct{}{}
isKeyDown = false
}
```
**特性**
- 释放检测延迟:通常 10-20ms
- 仅在按键按下后激活检测
- 依赖于 `GetAsyncKeyState` 的精度
#### 3. 持续按住行为
Windows 在持续按住热键时会重复发送 `WM_HOTKEY` 消息。本库通过 `isKeyDown` 标志防止同一次按住重复触发 Keydown 事件。
如果需要防止快速连续按键,建议在应用层添加防抖:
```go
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 等)
- 其他全局热键应用
返回错误为 `ErrFailedToRegister`Windows 不区分冲突和其他失败)。
#### 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
//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() 在主线程执行操作**
```go
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**
- **Windows**10ms 轮询,约 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-20ms`GetAsyncKeyState` 轮询检测)
- 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 显示服务器
**解决方案**
```bash
# 检查 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. 提供友好的错误提示,说明热键被占用
**调试方法**
```go
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 上正常(有轻微延迟)
**解决方案**
```go
// 确保在处理完事件后再注销
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** 🚀

View File

@@ -0,0 +1,191 @@
//go:build darwin
package darwin
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa -framework Carbon
#include <stdint.h>
#import <Cocoa/Cocoa.h>
#import <Carbon/Carbon.h>
extern void keydownCallback(uintptr_t handle);
extern void keyupCallback(uintptr_t handle);
int registerHotKey(int mod, int key, uintptr_t handle, EventHotKeyRef* ref);
int unregisterHotKey(EventHotKeyRef ref);
*/
import "C"
import (
"errors"
"fmt"
"runtime/cgo"
"sync"
)
// Standard errors
var (
ErrAlreadyRegistered = errors.New("hotkey: already registered")
ErrNotRegistered = errors.New("hotkey: not registered")
ErrFailedToRegister = errors.New("hotkey: failed to register")
ErrFailedToUnregister = errors.New("hotkey: failed to unregister")
)
// PlatformHotkey is a combination of modifiers and key to trigger an event
type PlatformHotkey struct {
mu sync.Mutex
registered bool
hkref C.EventHotKeyRef
handle cgo.Handle
}
func (ph *PlatformHotkey) Register(mods []Modifier, key Key, keydownIn, keyupIn chan<- interface{}) error {
ph.mu.Lock()
defer ph.mu.Unlock()
if ph.registered {
return ErrAlreadyRegistered
}
// Store callbacks info for C side
callbacks := &callbackData{
keydownIn: keydownIn,
keyupIn: keyupIn,
}
ph.handle = cgo.NewHandle(callbacks)
var mod Modifier
for _, m := range mods {
mod += m
}
ret := C.registerHotKey(C.int(mod), C.int(key), C.uintptr_t(ph.handle), &ph.hkref)
if ret == C.int(-1) {
ph.handle.Delete()
return fmt.Errorf("%w: Carbon API returned error", ErrFailedToRegister)
}
ph.registered = true
return nil
}
func (ph *PlatformHotkey) Unregister() error {
ph.mu.Lock()
defer ph.mu.Unlock()
if !ph.registered {
return ErrNotRegistered
}
ret := C.unregisterHotKey(ph.hkref)
if ret == C.int(-1) {
return fmt.Errorf("%w: Carbon API returned error", ErrFailedToUnregister)
}
// Clean up CGO handle
ph.handle.Delete()
ph.registered = false
return nil
}
type callbackData struct {
keydownIn chan<- interface{}
keyupIn chan<- interface{}
}
//export keydownCallback
func keydownCallback(h uintptr) {
cb := cgo.Handle(h).Value().(*callbackData)
cb.keydownIn <- struct{}{}
}
//export keyupCallback
func keyupCallback(h uintptr) {
cb := cgo.Handle(h).Value().(*callbackData)
cb.keyupIn <- struct{}{}
}
// Modifier represents a modifier.
// See: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
type Modifier uint32
// All kinds of Modifiers
const (
ModCtrl Modifier = 0x1000
ModShift Modifier = 0x200
ModOption Modifier = 0x800
ModCmd Modifier = 0x100
)
// Key represents a key.
// See: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
type Key uint8
// All kinds of keys
const (
KeySpace Key = 49
Key1 Key = 18
Key2 Key = 19
Key3 Key = 20
Key4 Key = 21
Key5 Key = 23
Key6 Key = 22
Key7 Key = 26
Key8 Key = 28
Key9 Key = 25
Key0 Key = 29
KeyA Key = 0
KeyB Key = 11
KeyC Key = 8
KeyD Key = 2
KeyE Key = 14
KeyF Key = 3
KeyG Key = 5
KeyH Key = 4
KeyI Key = 34
KeyJ Key = 38
KeyK Key = 40
KeyL Key = 37
KeyM Key = 46
KeyN Key = 45
KeyO Key = 31
KeyP Key = 35
KeyQ Key = 12
KeyR Key = 15
KeyS Key = 1
KeyT Key = 17
KeyU Key = 32
KeyV Key = 9
KeyW Key = 13
KeyX Key = 7
KeyY Key = 16
KeyZ Key = 6
KeyReturn Key = 0x24
KeyEscape Key = 0x35
KeyDelete Key = 0x33
KeyTab Key = 0x30
KeyLeft Key = 0x7B
KeyRight Key = 0x7C
KeyUp Key = 0x7E
KeyDown Key = 0x7D
KeyF1 Key = 0x7A
KeyF2 Key = 0x78
KeyF3 Key = 0x63
KeyF4 Key = 0x76
KeyF5 Key = 0x60
KeyF6 Key = 0x61
KeyF7 Key = 0x62
KeyF8 Key = 0x64
KeyF9 Key = 0x65
KeyF10 Key = 0x6D
KeyF11 Key = 0x67
KeyF12 Key = 0x6F
KeyF13 Key = 0x69
KeyF14 Key = 0x6B
KeyF15 Key = 0x71
KeyF16 Key = 0x6A
KeyF17 Key = 0x40
KeyF18 Key = 0x4F
KeyF19 Key = 0x50
KeyF20 Key = 0x5A
)

View File

@@ -1,9 +1,3 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
#include <stdint.h>

View File

@@ -1,12 +1,6 @@
// Copyright 2022 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
package mainthread
package darwin
/*
#cgo CFLAGS: -x objective-c
@@ -36,10 +30,15 @@ func Call(f func()) {
f()
return
}
done := make(chan struct{})
go func() {
mainFuncs <- f
mainFuncs <- func() {
f()
close(done)
}
C.wakeupMainThread()
}()
<-done
}
// Init initializes the functionality of running arbitrary subsequent functions be called on the main system thread.

View File

@@ -0,0 +1,23 @@
//go:build darwin
#include <stdint.h>
#import <Cocoa/Cocoa.h>
extern void dispatchMainFuncs();
void wakeupMainThread(void) {
dispatch_async(dispatch_get_main_queue(), ^{
dispatchMainFuncs();
});
}
// The following three lines of code must run on the main thread.
// For GUI applications (Wails, Cocoa), the framework handles this automatically.
// For CLI applications, see README for manual event loop setup.
//
// Inspired from: https://github.com/cehoffman/dotfiles/blob/4be8e893517e970d40746a9bdc67fe5832dd1c33/os/mac/iTerm2HotKey.m
void os_main(void) {
[NSApplication sharedApplication];
[NSApp disableRelaunchOnLogin];
[NSApp run];
}

View File

@@ -1,71 +1,106 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
// Package hotkey provides a high-performance, thread-safe facility to register
// system-level global hotkey shortcuts. Applications can be notified when users
// trigger hotkeys. A hotkey consists of a combination of modifier keys (Ctrl, Alt,
// Shift, etc.) and a single key.
//
// Written by Changkun Ou <changkun.de>
// Package hotkey provides the basic facility to register a system-level
// global hotkey shortcut so that an application can be notified if a user
// triggers the desired hotkey. A hotkey must be a combination of modifiers
// and a single key.
// # Basic Usage
//
// Note platform specific details:
// hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
// if err := hk.Register(); err != nil {
// log.Fatal(err)
// }
// defer hk.Close()
//
// - On macOS, due to the OS restriction (other platforms does not have
// this restriction), hotkey events must be handled on the "main thread".
// Therefore, in order to use this package properly, one must start an
// OS main event loop on the main thread, For self-contained applications,
// using [mainthread] package.
// is possible. It is uncessary or applications based on other GUI frameworks,
// such as fyne, ebiten, or Gio. See the "[examples]" for more examples.
//
// - On Linux (X11), when AutoRepeat is enabled in the X server, the
// Keyup is triggered automatically and continuously as Keydown continues.
//
// - On Linux (X11), some keys may be mapped to multiple Mod keys. To
// correctly register the key combination, one must use the correct
// underlying keycode combination. For example, a regular Ctrl+Alt+S
// might be registered as: Ctrl+Mod2+Mod4+S.
//
// - If this package did not include a desired key, one can always provide
// the keycode to the API. For example, if a key code is 0x15, then the
// corresponding key is `hotkey.Key(0x15)`.
//
// THe following is a minimum example:
//
// package main
//
// import (
// "log"
//
// "golang.design/x/hotkey"
// "golang.design/x/hotkey/mainthread"
// )
//
// func main() { mainthread.Init(fn) } // Not necessary when use in Fyne, Ebiten or Gio.
// func fn() {
// hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
// err := hk.Register()
// if err != nil {
// log.Fatalf("hotkey: failed to register hotkey: %v", err)
// }
//
// log.Printf("hotkey: %v is registered\n", hk)
// <-hk.Keydown()
// log.Printf("hotkey: %v is down\n", hk)
// <-hk.Keyup()
// log.Printf("hotkey: %v is up\n", hk)
// hk.Unregister()
// log.Printf("hotkey: %v is unregistered\n", hk)
// for {
// select {
// case <-hk.Keydown():
// fmt.Println("Hotkey pressed!")
// case <-hk.Keyup():
// fmt.Println("Hotkey released!")
// }
// }
//
// [mainthread]: https://pkg.go.dev/golang.design/x/hotkey/mainthread
// [examples]: https://github.com/golang-design/hotkey/tree/main/examples
// # Error Handling
//
// The package provides standardized error types for robust error handling:
//
// if err := hk.Register(); err != nil {
// switch {
// case errors.Is(err, hotkey.ErrHotkeyConflict):
// // Key combination already grabbed by another application
// case errors.Is(err, hotkey.ErrPlatformUnavailable):
// // Platform support unavailable (e.g., Linux without X11)
// case errors.Is(err, hotkey.ErrAlreadyRegistered):
// // Hotkey already registered
// }
// }
//
// # Platform-Specific Notes
//
// Linux (X11):
// - Requires libx11-dev: `sudo apt install -y libx11-dev`
// - For headless environments, use Xvfb virtual display
// - AutoRepeat may cause repeated Keydown events - implement debouncing if needed
// - Display connection is kept open during registration for optimal performance
// - Conflict detection: XSetErrorHandler catches BadAccess and returns ErrHotkeyConflict
//
// macOS:
// - For GUI applications (like Wails): works out of the box
// - For pure CLI applications: use darwin.Init(yourMainFunc) to start event loop
// - Advanced: use darwin.Call(func) to execute code on main thread
// - May require Accessibility permissions in System Preferences
// - Uses Carbon API with GCD (dispatch_get_main_queue)
//
// Windows:
// - No additional dependencies required
// - Keyup events simulated via GetAsyncKeyState polling (10-30ms delay)
// - Some system hotkeys (Win+L, Ctrl+Alt+Del) are reserved
//
// # Resource Management
//
// Always use Close() to release resources:
//
// hk := hotkey.New(mods, key)
// defer hk.Close() // Safe to call multiple times
//
// if err := hk.Register(); err != nil {
// return err
// }
// // ... use hotkey ...
//
// After Unregister() or Close(), you must re-obtain channel references:
//
// hk.Unregister()
// // ... modify hotkey ...
// hk.Register()
// keydownChan := hk.Keydown() // Get new channel reference
//
// # Performance
//
// - Memory: ~1KB per hotkey
// - Goroutines: 3 per hotkey (event loop + 2 channel converters)
// - Latency: Keydown < 10ms, Keyup < 30ms (Windows polling overhead)
// - Thread-safe: All public APIs use mutex protection
//
// For complete documentation and examples, see README.md in this package.
package hotkey
import (
"errors"
"fmt"
"runtime"
"sync"
)
// Standard errors
var (
ErrAlreadyRegistered = errors.New("hotkey: already registered")
ErrNotRegistered = errors.New("hotkey: not registered")
ErrClosed = errors.New("hotkey: hotkey has been closed")
ErrFailedToRegister = errors.New("hotkey: failed to register")
ErrFailedToUnregister = errors.New("hotkey: failed to unregister")
ErrHotkeyConflict = errors.New("hotkey: hotkey conflict with other applications")
ErrPlatformUnavailable = errors.New("hotkey: platform support unavailable")
)
// Event represents a hotkey event
@@ -82,36 +117,78 @@ type Hotkey struct {
keydownOut <-chan Event
keyupIn chan<- Event
keyupOut <-chan Event
// 用于停止 newEventChan goroutines
eventChansWg sync.WaitGroup
// 状态管理
mu sync.RWMutex
registered bool
closed bool
// 用于防止 Finalizer 和 Unregister 并发
finalizerMu sync.Mutex
finalized bool
}
// New creates a new hotkey for the given modifiers and keycode.
func New(mods []Modifier, key Key) *Hotkey {
keydownIn, keydownOut := newEventChan()
keyupIn, keyupOut := newEventChan()
hk := &Hotkey{
mods: mods,
key: key,
keydownIn: keydownIn,
keydownOut: keydownOut,
keyupIn: keyupIn,
keyupOut: keyupOut,
mods: mods,
key: key,
}
hk.eventChansWg.Add(2)
keydownIn, keydownOut := newEventChan(&hk.eventChansWg)
keyupIn, keyupOut := newEventChan(&hk.eventChansWg)
hk.keydownIn = keydownIn
hk.keydownOut = keydownOut
hk.keyupIn = keyupIn
hk.keyupOut = keyupOut
// Make sure the hotkey is unregistered when the created
// hotkey is garbage collected.
// Note: This is a safety net only. Users should explicitly call Unregister().
runtime.SetFinalizer(hk, func(x interface{}) {
hk := x.(*Hotkey)
hk.unregister()
close(hk.keydownIn)
close(hk.keyupIn)
hk.finalizerMu.Lock()
defer hk.finalizerMu.Unlock()
if hk.finalized {
return
}
hk.finalized = true
// Best effort cleanup - ignore errors
_ = hk.unregister()
})
return hk
}
// Register registers a combination of hotkeys. If the hotkey has
// registered. This function will invalidates the old registration
// and overwrites its callback.
func (hk *Hotkey) Register() error { return hk.register() }
// already been registered, this function will return an error.
// Use Unregister first if you want to re-register.
func (hk *Hotkey) Register() error {
hk.mu.Lock()
if hk.closed {
hk.mu.Unlock()
return ErrClosed
}
if hk.registered {
hk.mu.Unlock()
return ErrAlreadyRegistered
}
hk.mu.Unlock()
err := hk.register()
if err == nil {
hk.mu.Lock()
hk.registered = true
hk.mu.Unlock()
}
return err
}
// Keydown returns a channel that receives a signal when the hotkey is triggered.
func (hk *Hotkey) Keydown() <-chan Event { return hk.keydownOut }
@@ -119,21 +196,96 @@ func (hk *Hotkey) Keydown() <-chan Event { return hk.keydownOut }
// Keyup returns a channel that receives a signal when the hotkey is released.
func (hk *Hotkey) Keyup() <-chan Event { return hk.keyupOut }
// Unregister unregisters the hotkey.
// Unregister unregisters the hotkey. After unregister, the hotkey can be
// registered again with Register(). If you don't plan to reuse the hotkey,
// use Close() instead for proper cleanup.
func (hk *Hotkey) Unregister() error {
hk.mu.Lock()
if hk.closed {
hk.mu.Unlock()
return ErrClosed
}
if !hk.registered {
hk.mu.Unlock()
return ErrNotRegistered
}
hk.mu.Unlock()
err := hk.unregister()
if err != nil {
return err
}
// Reset a new event channel.
hk.mu.Lock()
hk.registered = false
hk.mu.Unlock()
// Close old event channels and wait for goroutines to exit
close(hk.keydownIn)
close(hk.keyupIn)
hk.keydownIn, hk.keydownOut = newEventChan()
hk.keyupIn, hk.keyupOut = newEventChan()
hk.eventChansWg.Wait()
// Reset new event channels for potential re-registration
hk.eventChansWg.Add(2)
hk.keydownIn, hk.keydownOut = newEventChan(&hk.eventChansWg)
hk.keyupIn, hk.keyupOut = newEventChan(&hk.eventChansWg)
return nil
}
// Close unregisters the hotkey and releases all resources.
// After Close(), the hotkey cannot be used again. This is the recommended
// way to cleanup resources when you're done with the hotkey.
// Close is safe to call multiple times.
func (hk *Hotkey) Close() error {
hk.finalizerMu.Lock()
if hk.finalized {
hk.finalizerMu.Unlock()
return nil
}
hk.finalized = true
hk.finalizerMu.Unlock()
hk.mu.Lock()
if hk.closed {
hk.mu.Unlock()
return nil
}
hk.closed = true
wasRegistered := hk.registered
hk.registered = false
hk.mu.Unlock()
var err error
if wasRegistered {
err = hk.unregister()
}
// Close event channels and wait for goroutines
close(hk.keydownIn)
close(hk.keyupIn)
hk.eventChansWg.Wait()
// Remove finalizer since we're cleaning up properly
runtime.SetFinalizer(hk, nil)
return err
}
// IsRegistered returns true if the hotkey is currently registered.
func (hk *Hotkey) IsRegistered() bool {
hk.mu.RLock()
defer hk.mu.RUnlock()
return hk.registered && !hk.closed
}
// IsClosed returns true if the hotkey has been closed.
func (hk *Hotkey) IsClosed() bool {
hk.mu.RLock()
defer hk.mu.RUnlock()
return hk.closed
}
// String returns a string representation of the hotkey.
func (hk *Hotkey) String() string {
s := fmt.Sprintf("%v", hk.key)
@@ -145,10 +297,11 @@ func (hk *Hotkey) String() string {
// newEventChan returns a sender and a receiver of a buffered channel
// with infinite capacity.
func newEventChan() (chan<- Event, <-chan Event) {
func newEventChan(wg *sync.WaitGroup) (chan<- Event, <-chan Event) {
in, out := make(chan Event), make(chan Event)
go func() {
defer wg.Done()
var q []Event
for {

View File

@@ -1,176 +1,152 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
package hotkey
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa -framework Carbon
#include <stdint.h>
#import <Cocoa/Cocoa.h>
#import <Carbon/Carbon.h>
import "voidraft/internal/common/hotkey/darwin"
extern void keydownCallback(uintptr_t handle);
extern void keyupCallback(uintptr_t handle);
int registerHotKey(int mod, int key, uintptr_t handle, EventHotKeyRef* ref);
int unregisterHotKey(EventHotKeyRef ref);
*/
import "C"
import (
"errors"
"runtime/cgo"
"sync"
)
// Hotkey is a combination of modifiers and key to trigger an event
type platformHotkey struct {
mu sync.Mutex
registered bool
hkref C.EventHotKeyRef
}
func (hk *Hotkey) register() error {
hk.mu.Lock()
defer hk.mu.Unlock()
if hk.registered {
return errors.New("hotkey already registered")
}
// Note: we use handle number as hotkey id in the C side.
// A cgo handle could ran out of space, but since in hotkey purpose
// we won't have that much number of hotkeys. So this should be fine.
h := cgo.NewHandle(hk)
var mod Modifier
for _, m := range hk.mods {
mod += m
}
ret := C.registerHotKey(C.int(mod), C.int(hk.key), C.uintptr_t(h), &hk.hkref)
if ret == C.int(-1) {
return errors.New("failed to register the hotkey")
}
hk.registered = true
return nil
}
func (hk *Hotkey) unregister() error {
hk.mu.Lock()
defer hk.mu.Unlock()
if !hk.registered {
return errors.New("hotkey is not registered")
}
ret := C.unregisterHotKey(hk.hkref)
if ret == C.int(-1) {
return errors.New("failed to unregister the current hotkey")
}
hk.registered = false
return nil
}
//export keydownCallback
func keydownCallback(h uintptr) {
hk := cgo.Handle(h).Value().(*Hotkey)
hk.keydownIn <- Event{}
}
//export keyupCallback
func keyupCallback(h uintptr) {
hk := cgo.Handle(h).Value().(*Hotkey)
hk.keyupIn <- Event{}
ph darwin.PlatformHotkey
keydownIn chan interface{}
keyupIn chan interface{}
stopChans chan struct{} // 用于停止通道转换 goroutines
}
// Modifier represents a modifier.
// See: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
type Modifier uint32
type Modifier = darwin.Modifier
// All kinds of Modifiers
const (
ModCtrl Modifier = 0x1000
ModShift Modifier = 0x200
ModOption Modifier = 0x800
ModCmd Modifier = 0x100
ModCtrl = darwin.ModCtrl
ModShift = darwin.ModShift
ModOption = darwin.ModOption
ModCmd = darwin.ModCmd
)
// Key represents a key.
// See: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
type Key uint8
type Key = darwin.Key
// All kinds of keys
const (
KeySpace Key = 49
Key1 Key = 18
Key2 Key = 19
Key3 Key = 20
Key4 Key = 21
Key5 Key = 23
Key6 Key = 22
Key7 Key = 26
Key8 Key = 28
Key9 Key = 25
Key0 Key = 29
KeyA Key = 0
KeyB Key = 11
KeyC Key = 8
KeyD Key = 2
KeyE Key = 14
KeyF Key = 3
KeyG Key = 5
KeyH Key = 4
KeyI Key = 34
KeyJ Key = 38
KeyK Key = 40
KeyL Key = 37
KeyM Key = 46
KeyN Key = 45
KeyO Key = 31
KeyP Key = 35
KeyQ Key = 12
KeyR Key = 15
KeyS Key = 1
KeyT Key = 17
KeyU Key = 32
KeyV Key = 9
KeyW Key = 13
KeyX Key = 7
KeyY Key = 16
KeyZ Key = 6
KeySpace = darwin.KeySpace
Key1 = darwin.Key1
Key2 = darwin.Key2
Key3 = darwin.Key3
Key4 = darwin.Key4
Key5 = darwin.Key5
Key6 = darwin.Key6
Key7 = darwin.Key7
Key8 = darwin.Key8
Key9 = darwin.Key9
Key0 = darwin.Key0
KeyA = darwin.KeyA
KeyB = darwin.KeyB
KeyC = darwin.KeyC
KeyD = darwin.KeyD
KeyE = darwin.KeyE
KeyF = darwin.KeyF
KeyG = darwin.KeyG
KeyH = darwin.KeyH
KeyI = darwin.KeyI
KeyJ = darwin.KeyJ
KeyK = darwin.KeyK
KeyL = darwin.KeyL
KeyM = darwin.KeyM
KeyN = darwin.KeyN
KeyO = darwin.KeyO
KeyP = darwin.KeyP
KeyQ = darwin.KeyQ
KeyR = darwin.KeyR
KeyS = darwin.KeyS
KeyT = darwin.KeyT
KeyU = darwin.KeyU
KeyV = darwin.KeyV
KeyW = darwin.KeyW
KeyX = darwin.KeyX
KeyY = darwin.KeyY
KeyZ = darwin.KeyZ
KeyReturn Key = 0x24
KeyEscape Key = 0x35
KeyDelete Key = 0x33
KeyTab Key = 0x30
KeyReturn = darwin.KeyReturn
KeyEscape = darwin.KeyEscape
KeyDelete = darwin.KeyDelete
KeyTab = darwin.KeyTab
KeyLeft Key = 0x7B
KeyRight Key = 0x7C
KeyUp Key = 0x7E
KeyDown Key = 0x7D
KeyLeft = darwin.KeyLeft
KeyRight = darwin.KeyRight
KeyUp = darwin.KeyUp
KeyDown = darwin.KeyDown
KeyF1 Key = 0x7A
KeyF2 Key = 0x78
KeyF3 Key = 0x63
KeyF4 Key = 0x76
KeyF5 Key = 0x60
KeyF6 Key = 0x61
KeyF7 Key = 0x62
KeyF8 Key = 0x64
KeyF9 Key = 0x65
KeyF10 Key = 0x6D
KeyF11 Key = 0x67
KeyF12 Key = 0x6F
KeyF13 Key = 0x69
KeyF14 Key = 0x6B
KeyF15 Key = 0x71
KeyF16 Key = 0x6A
KeyF17 Key = 0x40
KeyF18 Key = 0x4F
KeyF19 Key = 0x50
KeyF20 Key = 0x5A
KeyF1 = darwin.KeyF1
KeyF2 = darwin.KeyF2
KeyF3 = darwin.KeyF3
KeyF4 = darwin.KeyF4
KeyF5 = darwin.KeyF5
KeyF6 = darwin.KeyF6
KeyF7 = darwin.KeyF7
KeyF8 = darwin.KeyF8
KeyF9 = darwin.KeyF9
KeyF10 = darwin.KeyF10
KeyF11 = darwin.KeyF11
KeyF12 = darwin.KeyF12
KeyF13 = darwin.KeyF13
KeyF14 = darwin.KeyF14
KeyF15 = darwin.KeyF15
KeyF16 = darwin.KeyF16
KeyF17 = darwin.KeyF17
KeyF18 = darwin.KeyF18
KeyF19 = darwin.KeyF19
KeyF20 = darwin.KeyF20
)
func (hk *Hotkey) register() error {
// Convert channels
hk.platformHotkey.keydownIn = make(chan interface{}, 1)
hk.platformHotkey.keyupIn = make(chan interface{}, 1)
hk.platformHotkey.stopChans = make(chan struct{})
// Start goroutines to convert interface{} events to Event{}
go func() {
for {
select {
case <-hk.platformHotkey.stopChans:
return
case <-hk.platformHotkey.keydownIn:
hk.keydownIn <- Event{}
}
}
}()
go func() {
for {
select {
case <-hk.platformHotkey.stopChans:
return
case <-hk.platformHotkey.keyupIn:
hk.keyupIn <- Event{}
}
}
}()
return hk.platformHotkey.ph.Register(hk.mods, hk.key, hk.platformHotkey.keydownIn, hk.platformHotkey.keyupIn)
}
func (hk *Hotkey) unregister() error {
// Stop channel conversion goroutines first
if hk.platformHotkey.stopChans != nil {
select {
case <-hk.platformHotkey.stopChans:
// Already closed, do nothing
default:
close(hk.platformHotkey.stopChans)
}
hk.platformHotkey.stopChans = nil
}
// Then unregister the hotkey
err := hk.platformHotkey.ph.Unregister()
// Close conversion channels (don't close, just set to nil)
// The goroutines will drain them when stopChans is closed
hk.platformHotkey.keydownIn = nil
hk.platformHotkey.keyupIn = nil
return err
}

View File

@@ -1,67 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin && cgo
package hotkey_test
import (
"context"
"fmt"
"testing"
"time"
"voidraft/internal/common/hotkey"
)
// TestHotkey should always run success.
// This is a test to run and for manually testing the registration of multiple
// hotkeys. Registered hotkeys:
// Ctrl+Shift+S
// Ctrl+Option+S
func TestHotkey(t *testing.T) {
tt := time.Second * 5
done := make(chan struct{}, 2)
ctx, cancel := context.WithTimeout(context.Background(), tt)
go func() {
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
if err := hk.Register(); err != nil {
t.Errorf("failed to register hotkey: %v", err)
return
}
for {
select {
case <-ctx.Done():
cancel()
done <- struct{}{}
return
case <-hk.Keydown():
fmt.Println("triggered ctrl+shift+s")
}
}
}()
go func() {
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModOption}, hotkey.KeyS)
if err := hk.Register(); err != nil {
t.Errorf("failed to register hotkey: %v", err)
return
}
for {
select {
case <-ctx.Done():
cancel()
done <- struct{}{}
return
case <-hk.Keydown():
fmt.Println("triggered ctrl+option+s")
}
}
}()
<-done
<-done
}

View File

@@ -1,77 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux
#include <stdint.h>
#include <stdio.h>
#include <X11/Xlib.h>
#include <X11/Xutil.h>
extern void hotkeyDown(uintptr_t hkhandle);
extern void hotkeyUp(uintptr_t hkhandle);
int displayTest() {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
break;
}
if (d == NULL) {
return -1;
}
return 0;
}
// FIXME: handle bad access properly.
// int handleErrors( Display* dpy, XErrorEvent* pErr )
// {
// printf("X Error Handler called, values: %d/%lu/%d/%d/%d\n",
// pErr->type,
// pErr->serial,
// pErr->error_code,
// pErr->request_code,
// pErr->minor_code );
// if( pErr->request_code == 33 ){ // 33 (X_GrabKey)
// if( pErr->error_code == BadAccess ){
// printf("ERROR: key combination already grabbed by another client.\n");
// return 0;
// }
// }
// return 0;
// }
// waitHotkey blocks until the hotkey is triggered.
// this function crashes the program if the hotkey already grabbed by others.
int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
break;
}
if (d == NULL) {
return -1;
}
int keycode = XKeysymToKeycode(d, key);
XGrabKey(d, keycode, mod, DefaultRootWindow(d), False, GrabModeAsync, GrabModeAsync);
XSelectInput(d, DefaultRootWindow(d), KeyPressMask);
XEvent ev;
while(1) {
XNextEvent(d, &ev);
switch(ev.type) {
case KeyPress:
hotkeyDown(hkhandle);
continue;
case KeyRelease:
hotkeyUp(hkhandle);
XUngrabKey(d, keycode, mod, DefaultRootWindow(d));
XCloseDisplay(d);
return 0;
}
}
}

View File

@@ -1,210 +1,156 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux
package hotkey
/*
#cgo LDFLAGS: -lX11
#include <stdint.h>
int displayTest();
int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key);
*/
import "C"
import (
"context"
"errors"
"runtime"
"runtime/cgo"
"sync"
)
const errmsg = `Failed to initialize the X11 display, and the clipboard package
will not work properly. Install the following dependency may help:
apt install -y libx11-dev
If the clipboard package is in an environment without a frame buffer,
such as a cloud server, it may also be necessary to install xvfb:
apt install -y xvfb
and initialize a virtual frame buffer:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0
Then this package should be ready to use.
`
func init() {
if C.displayTest() != 0 {
panic(errmsg)
}
}
import "voidraft/internal/common/hotkey/linux"
type platformHotkey struct {
mu sync.Mutex
registered bool
ctx context.Context
cancel context.CancelFunc
canceled chan struct{}
}
// Nothing needs to do for register
func (hk *Hotkey) register() error {
hk.mu.Lock()
if hk.registered {
hk.mu.Unlock()
return errors.New("hotkey already registered.")
}
hk.registered = true
hk.ctx, hk.cancel = context.WithCancel(context.Background())
hk.canceled = make(chan struct{})
hk.mu.Unlock()
go hk.handle()
return nil
}
// Nothing needs to do for unregister
func (hk *Hotkey) unregister() error {
hk.mu.Lock()
defer hk.mu.Unlock()
if !hk.registered {
return errors.New("hotkey is not registered.")
}
hk.cancel()
hk.registered = false
<-hk.canceled
return nil
}
// handle registers an application global hotkey to the system,
// and returns a channel that will signal if the hotkey is triggered.
func (hk *Hotkey) handle() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// KNOWN ISSUE: if a hotkey is grabbed by others, C side will crash the program
var mod Modifier
for _, m := range hk.mods {
mod = mod | m
}
h := cgo.NewHandle(hk)
defer h.Delete()
for {
select {
case <-hk.ctx.Done():
close(hk.canceled)
return
default:
_ = C.waitHotkey(C.uintptr_t(h), C.uint(mod), C.int(hk.key))
}
}
}
//export hotkeyDown
func hotkeyDown(h uintptr) {
hk := cgo.Handle(h).Value().(*Hotkey)
hk.keydownIn <- Event{}
}
//export hotkeyUp
func hotkeyUp(h uintptr) {
hk := cgo.Handle(h).Value().(*Hotkey)
hk.keyupIn <- Event{}
ph linux.PlatformHotkey
keydownIn chan interface{}
keyupIn chan interface{}
stopChans chan struct{} // 用于停止通道转换 goroutines
}
// Modifier represents a modifier.
type Modifier uint32
type Modifier = linux.Modifier
// All kinds of Modifiers
// See /usr/include/X11/X.h
const (
ModCtrl Modifier = (1 << 2)
ModShift Modifier = (1 << 0)
Mod1 Modifier = (1 << 3)
Mod2 Modifier = (1 << 4)
Mod3 Modifier = (1 << 5)
Mod4 Modifier = (1 << 6)
Mod5 Modifier = (1 << 7)
ModCtrl = linux.ModCtrl
ModShift = linux.ModShift
ModAlt = linux.ModAlt // Alias for Mod1
Mod1 = linux.Mod1
Mod2 = linux.Mod2
Mod3 = linux.Mod3
Mod4 = linux.Mod4
Mod5 = linux.Mod5
)
// Key represents a key.
// See /usr/include/X11/keysymdef.h
type Key uint16
type Key = linux.Key
// All kinds of keys
const (
KeySpace Key = 0x0020
Key1 Key = 0x0030
Key2 Key = 0x0031
Key3 Key = 0x0032
Key4 Key = 0x0033
Key5 Key = 0x0034
Key6 Key = 0x0035
Key7 Key = 0x0036
Key8 Key = 0x0037
Key9 Key = 0x0038
Key0 Key = 0x0039
KeyA Key = 0x0061
KeyB Key = 0x0062
KeyC Key = 0x0063
KeyD Key = 0x0064
KeyE Key = 0x0065
KeyF Key = 0x0066
KeyG Key = 0x0067
KeyH Key = 0x0068
KeyI Key = 0x0069
KeyJ Key = 0x006a
KeyK Key = 0x006b
KeyL Key = 0x006c
KeyM Key = 0x006d
KeyN Key = 0x006e
KeyO Key = 0x006f
KeyP Key = 0x0070
KeyQ Key = 0x0071
KeyR Key = 0x0072
KeyS Key = 0x0073
KeyT Key = 0x0074
KeyU Key = 0x0075
KeyV Key = 0x0076
KeyW Key = 0x0077
KeyX Key = 0x0078
KeyY Key = 0x0079
KeyZ Key = 0x007a
KeySpace = linux.KeySpace
Key1 = linux.Key1
Key2 = linux.Key2
Key3 = linux.Key3
Key4 = linux.Key4
Key5 = linux.Key5
Key6 = linux.Key6
Key7 = linux.Key7
Key8 = linux.Key8
Key9 = linux.Key9
Key0 = linux.Key0
KeyA = linux.KeyA
KeyB = linux.KeyB
KeyC = linux.KeyC
KeyD = linux.KeyD
KeyE = linux.KeyE
KeyF = linux.KeyF
KeyG = linux.KeyG
KeyH = linux.KeyH
KeyI = linux.KeyI
KeyJ = linux.KeyJ
KeyK = linux.KeyK
KeyL = linux.KeyL
KeyM = linux.KeyM
KeyN = linux.KeyN
KeyO = linux.KeyO
KeyP = linux.KeyP
KeyQ = linux.KeyQ
KeyR = linux.KeyR
KeyS = linux.KeyS
KeyT = linux.KeyT
KeyU = linux.KeyU
KeyV = linux.KeyV
KeyW = linux.KeyW
KeyX = linux.KeyX
KeyY = linux.KeyY
KeyZ = linux.KeyZ
KeyReturn Key = 0xff0d
KeyEscape Key = 0xff1b
KeyDelete Key = 0xffff
KeyTab Key = 0xff1b
KeyReturn = linux.KeyReturn
KeyEscape = linux.KeyEscape
KeyDelete = linux.KeyDelete
KeyTab = linux.KeyTab
KeyLeft Key = 0xff51
KeyRight Key = 0xff53
KeyUp Key = 0xff52
KeyDown Key = 0xff54
KeyLeft = linux.KeyLeft
KeyRight = linux.KeyRight
KeyUp = linux.KeyUp
KeyDown = linux.KeyDown
KeyF1 Key = 0xffbe
KeyF2 Key = 0xffbf
KeyF3 Key = 0xffc0
KeyF4 Key = 0xffc1
KeyF5 Key = 0xffc2
KeyF6 Key = 0xffc3
KeyF7 Key = 0xffc4
KeyF8 Key = 0xffc5
KeyF9 Key = 0xffc6
KeyF10 Key = 0xffc7
KeyF11 Key = 0xffc8
KeyF12 Key = 0xffc9
KeyF13 Key = 0xffca
KeyF14 Key = 0xffcb
KeyF15 Key = 0xffcc
KeyF16 Key = 0xffcd
KeyF17 Key = 0xffce
KeyF18 Key = 0xffcf
KeyF19 Key = 0xffd0
KeyF20 Key = 0xffd1
KeyF1 = linux.KeyF1
KeyF2 = linux.KeyF2
KeyF3 = linux.KeyF3
KeyF4 = linux.KeyF4
KeyF5 = linux.KeyF5
KeyF6 = linux.KeyF6
KeyF7 = linux.KeyF7
KeyF8 = linux.KeyF8
KeyF9 = linux.KeyF9
KeyF10 = linux.KeyF10
KeyF11 = linux.KeyF11
KeyF12 = linux.KeyF12
KeyF13 = linux.KeyF13
KeyF14 = linux.KeyF14
KeyF15 = linux.KeyF15
KeyF16 = linux.KeyF16
KeyF17 = linux.KeyF17
KeyF18 = linux.KeyF18
KeyF19 = linux.KeyF19
KeyF20 = linux.KeyF20
)
func (hk *Hotkey) register() error {
// Convert channels
hk.platformHotkey.keydownIn = make(chan interface{}, 1)
hk.platformHotkey.keyupIn = make(chan interface{}, 1)
hk.platformHotkey.stopChans = make(chan struct{})
// Start goroutines to convert interface{} events to Event{}
go func() {
for {
select {
case <-hk.platformHotkey.stopChans:
return
case <-hk.platformHotkey.keydownIn:
hk.keydownIn <- Event{}
}
}
}()
go func() {
for {
select {
case <-hk.platformHotkey.stopChans:
return
case <-hk.platformHotkey.keyupIn:
hk.keyupIn <- Event{}
}
}
}()
return hk.platformHotkey.ph.Register(hk.mods, hk.key, hk.platformHotkey.keydownIn, hk.platformHotkey.keyupIn)
}
func (hk *Hotkey) unregister() error {
// Stop channel conversion goroutines first
if hk.platformHotkey.stopChans != nil {
select {
case <-hk.platformHotkey.stopChans:
// Already closed, do nothing
default:
close(hk.platformHotkey.stopChans)
}
hk.platformHotkey.stopChans = nil
}
// Then unregister the hotkey
err := hk.platformHotkey.ph.Unregister()
// Close conversion channels (don't close, just set to nil)
// The goroutines will drain them when stopChans is closed
hk.platformHotkey.keydownIn = nil
hk.platformHotkey.keyupIn = nil
return err
}

View File

@@ -1,41 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux && cgo
package hotkey_test
import (
"context"
"fmt"
"testing"
"time"
"voidraft/internal/common/hotkey"
)
// TestHotkey should always run success.
// This is a test to run and for manually testing, registered combination:
// Ctrl+Alt+A (Ctrl+Mod2+Mod4+A on Linux)
func TestHotkey(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
hk := hotkey.New([]hotkey.Modifier{
hotkey.ModCtrl, hotkey.Mod2, hotkey.Mod4}, hotkey.KeyA)
if err := hk.Register(); err != nil {
t.Errorf("failed to register hotkey: %v", err)
return
}
for {
select {
case <-ctx.Done():
return
case <-hk.Keydown():
fmt.Println("triggered")
}
}
}

View File

@@ -1,9 +1,3 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build !windows && !cgo
package hotkey

View File

@@ -1,34 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build (linux || darwin) && !cgo
package hotkey_test
import (
"testing"
"voidraft/internal/common/hotkey"
)
// TestHotkey should always run success.
// This is a test to run and for manually testing, registered combination:
// Ctrl+Alt+A (Ctrl+Mod2+Mod4+A on Linux)
func TestHotkey(t *testing.T) {
defer func() {
if r := recover(); r != nil {
return
}
t.Fatalf("expect to fail when CGO_ENABLED=0")
}()
hk := hotkey.New([]hotkey.Modifier{}, hotkey.Key(0))
err := hk.Register()
if err != nil {
t.Fatal(err)
}
hk.Unregister()
}

View File

@@ -1,19 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
package hotkey_test
import (
"os"
"testing"
"voidraft/internal/common/hotkey/mainthread"
)
// The test cannot be run twice since the mainthread loop may not be terminated:
// go test -v -count=1
func TestMain(m *testing.M) {
mainthread.Init(func() { os.Exit(m.Run()) })
}

View File

@@ -1,224 +1,152 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build windows
package hotkey
import (
"errors"
"runtime"
"sync"
"sync/atomic"
"time"
"voidraft/internal/common/hotkey/internal/win"
)
import "voidraft/internal/common/hotkey/windows"
type platformHotkey struct {
mu sync.Mutex
hotkeyId uint64
registered bool
funcs chan func()
canceled chan struct{}
}
var hotkeyId uint64 // atomic
// register registers a system hotkey. It returns an error if
// the registration is failed. This could be that the hotkey is
// conflict with other hotkeys.
func (hk *Hotkey) register() error {
hk.mu.Lock()
if hk.registered {
hk.mu.Unlock()
return errors.New("hotkey already registered")
}
mod := uint8(0)
for _, m := range hk.mods {
mod = mod | uint8(m)
}
hk.hotkeyId = atomic.AddUint64(&hotkeyId, 1)
hk.funcs = make(chan func())
hk.canceled = make(chan struct{})
go hk.handle()
var (
ok bool
err error
done = make(chan struct{})
)
hk.funcs <- func() {
ok, err = win.RegisterHotKey(0, uintptr(hk.hotkeyId), uintptr(mod), uintptr(hk.key))
done <- struct{}{}
}
<-done
if !ok {
close(hk.canceled)
hk.mu.Unlock()
return err
}
hk.registered = true
hk.mu.Unlock()
return nil
}
// unregister deregisteres a system hotkey.
func (hk *Hotkey) unregister() error {
hk.mu.Lock()
defer hk.mu.Unlock()
if !hk.registered {
return errors.New("hotkey is not registered")
}
done := make(chan struct{})
hk.funcs <- func() {
win.UnregisterHotKey(0, uintptr(hk.hotkeyId))
done <- struct{}{}
close(hk.canceled)
}
<-done
<-hk.canceled
hk.registered = false
return nil
}
const (
// wmHotkey represents hotkey message
wmHotkey uint32 = 0x0312
wmQuit uint32 = 0x0012
)
// handle handles the hotkey event loop.
func (hk *Hotkey) handle() {
// We could optimize this. So far each hotkey is served in an
// individual thread. If we have too many hotkeys, then a program
// have to create too many threads to serve them.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
isKeyDown := false
tk := time.NewTicker(time.Second / 100)
for range tk.C {
msg := win.MSG{}
if !win.PeekMessage(&msg, 0, 0, 0) {
select {
case f := <-hk.funcs:
f()
case <-hk.canceled:
return
default:
// If the latest status is KeyDown, and AsyncKeyState is 0, consider key is up.
if win.GetAsyncKeyState(int(hk.key)) == 0 && isKeyDown {
hk.keyupIn <- Event{}
isKeyDown = false
}
}
continue
}
if !win.GetMessage(&msg, 0, 0, 0) {
return
}
switch msg.Message {
case wmHotkey:
hk.keydownIn <- Event{}
isKeyDown = true
case wmQuit:
return
}
}
ph windows.PlatformHotkey
keydownIn chan interface{}
keyupIn chan interface{}
stopChans chan struct{} // 用于停止通道转换 goroutines
}
// Modifier represents a modifier.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey
type Modifier uint8
type Modifier = windows.Modifier
// All kinds of Modifiers
const (
ModAlt Modifier = 0x1
ModCtrl Modifier = 0x2
ModShift Modifier = 0x4
ModWin Modifier = 0x8
ModAlt = windows.ModAlt
ModCtrl = windows.ModCtrl
ModShift = windows.ModShift
ModWin = windows.ModWin
)
// Key represents a key.
// https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
type Key uint16
type Key = windows.Key
// All kinds of Keys
const (
KeySpace Key = 0x20
Key0 Key = 0x30
Key1 Key = 0x31
Key2 Key = 0x32
Key3 Key = 0x33
Key4 Key = 0x34
Key5 Key = 0x35
Key6 Key = 0x36
Key7 Key = 0x37
Key8 Key = 0x38
Key9 Key = 0x39
KeyA Key = 0x41
KeyB Key = 0x42
KeyC Key = 0x43
KeyD Key = 0x44
KeyE Key = 0x45
KeyF Key = 0x46
KeyG Key = 0x47
KeyH Key = 0x48
KeyI Key = 0x49
KeyJ Key = 0x4A
KeyK Key = 0x4B
KeyL Key = 0x4C
KeyM Key = 0x4D
KeyN Key = 0x4E
KeyO Key = 0x4F
KeyP Key = 0x50
KeyQ Key = 0x51
KeyR Key = 0x52
KeyS Key = 0x53
KeyT Key = 0x54
KeyU Key = 0x55
KeyV Key = 0x56
KeyW Key = 0x57
KeyX Key = 0x58
KeyY Key = 0x59
KeyZ Key = 0x5A
KeySpace = windows.KeySpace
Key0 = windows.Key0
Key1 = windows.Key1
Key2 = windows.Key2
Key3 = windows.Key3
Key4 = windows.Key4
Key5 = windows.Key5
Key6 = windows.Key6
Key7 = windows.Key7
Key8 = windows.Key8
Key9 = windows.Key9
KeyA = windows.KeyA
KeyB = windows.KeyB
KeyC = windows.KeyC
KeyD = windows.KeyD
KeyE = windows.KeyE
KeyF = windows.KeyF
KeyG = windows.KeyG
KeyH = windows.KeyH
KeyI = windows.KeyI
KeyJ = windows.KeyJ
KeyK = windows.KeyK
KeyL = windows.KeyL
KeyM = windows.KeyM
KeyN = windows.KeyN
KeyO = windows.KeyO
KeyP = windows.KeyP
KeyQ = windows.KeyQ
KeyR = windows.KeyR
KeyS = windows.KeyS
KeyT = windows.KeyT
KeyU = windows.KeyU
KeyV = windows.KeyV
KeyW = windows.KeyW
KeyX = windows.KeyX
KeyY = windows.KeyY
KeyZ = windows.KeyZ
KeyReturn Key = 0x0D
KeyEscape Key = 0x1B
KeyDelete Key = 0x2E
KeyTab Key = 0x09
KeyReturn = windows.KeyReturn
KeyEscape = windows.KeyEscape
KeyDelete = windows.KeyDelete
KeyTab = windows.KeyTab
KeyLeft Key = 0x25
KeyRight Key = 0x27
KeyUp Key = 0x26
KeyDown Key = 0x28
KeyLeft = windows.KeyLeft
KeyRight = windows.KeyRight
KeyUp = windows.KeyUp
KeyDown = windows.KeyDown
KeyF1 Key = 0x70
KeyF2 Key = 0x71
KeyF3 Key = 0x72
KeyF4 Key = 0x73
KeyF5 Key = 0x74
KeyF6 Key = 0x75
KeyF7 Key = 0x76
KeyF8 Key = 0x77
KeyF9 Key = 0x78
KeyF10 Key = 0x79
KeyF11 Key = 0x7A
KeyF12 Key = 0x7B
KeyF13 Key = 0x7C
KeyF14 Key = 0x7D
KeyF15 Key = 0x7E
KeyF16 Key = 0x7F
KeyF17 Key = 0x80
KeyF18 Key = 0x81
KeyF19 Key = 0x82
KeyF20 Key = 0x83
KeyF1 = windows.KeyF1
KeyF2 = windows.KeyF2
KeyF3 = windows.KeyF3
KeyF4 = windows.KeyF4
KeyF5 = windows.KeyF5
KeyF6 = windows.KeyF6
KeyF7 = windows.KeyF7
KeyF8 = windows.KeyF8
KeyF9 = windows.KeyF9
KeyF10 = windows.KeyF10
KeyF11 = windows.KeyF11
KeyF12 = windows.KeyF12
KeyF13 = windows.KeyF13
KeyF14 = windows.KeyF14
KeyF15 = windows.KeyF15
KeyF16 = windows.KeyF16
KeyF17 = windows.KeyF17
KeyF18 = windows.KeyF18
KeyF19 = windows.KeyF19
KeyF20 = windows.KeyF20
)
func (hk *Hotkey) register() error {
// Convert channels
hk.platformHotkey.keydownIn = make(chan interface{}, 1)
hk.platformHotkey.keyupIn = make(chan interface{}, 1)
hk.platformHotkey.stopChans = make(chan struct{})
// Start goroutines to convert interface{} events to Event{}
go func() {
for {
select {
case <-hk.platformHotkey.stopChans:
return
case <-hk.platformHotkey.keydownIn:
hk.keydownIn <- Event{}
}
}
}()
go func() {
for {
select {
case <-hk.platformHotkey.stopChans:
return
case <-hk.platformHotkey.keyupIn:
hk.keyupIn <- Event{}
}
}
}()
return hk.platformHotkey.ph.Register(hk.mods, hk.key, hk.platformHotkey.keydownIn, hk.platformHotkey.keyupIn)
}
func (hk *Hotkey) unregister() error {
// Stop channel conversion goroutines first
if hk.platformHotkey.stopChans != nil {
select {
case <-hk.platformHotkey.stopChans:
// Already closed, do nothing
default:
close(hk.platformHotkey.stopChans)
}
hk.platformHotkey.stopChans = nil
}
// Then unregister the hotkey
err := hk.platformHotkey.ph.Unregister()
// Close conversion channels (don't close, just set to nil)
// The goroutines will drain them when stopChans is closed
hk.platformHotkey.keydownIn = nil
hk.platformHotkey.keyupIn = nil
return err
}

View File

@@ -1,41 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build windows
package hotkey_test
import (
"context"
"fmt"
"testing"
"time"
"voidraft/internal/common/hotkey"
)
// TestHotkey should always run success.
// This is a test to run and for manually testing, registered combination:
// Ctrl+Shift+S
func TestHotkey(t *testing.T) {
tt := time.Second * 5
ctx, cancel := context.WithTimeout(context.Background(), tt)
defer cancel()
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
if err := hk.Register(); err != nil {
t.Errorf("failed to register hotkey: %v", err)
return
}
for {
select {
case <-ctx.Done():
return
case <-hk.Keydown():
fmt.Println("triggered")
}
}
}

View File

@@ -1,122 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build windows
// +build windows
package win
import (
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32")
registerHotkey = user32.NewProc("RegisterHotKey")
unregisterHotkey = user32.NewProc("UnregisterHotKey")
getMessage = user32.NewProc("GetMessageW")
peekMessage = user32.NewProc("PeekMessageA")
sendMessage = user32.NewProc("SendMessageW")
getAsyncKeyState = user32.NewProc("GetAsyncKeyState")
quitMessage = user32.NewProc("PostQuitMessage")
)
// RegisterHotKey defines a system-wide hot key.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey
func RegisterHotKey(hwnd, id uintptr, mod uintptr, k uintptr) (bool, error) {
ret, _, err := registerHotkey.Call(
hwnd, id, mod, k,
)
return ret != 0, err
}
// UnregisterHotKey frees a hot key previously registered by the calling
// thread.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-unregisterhotkey
func UnregisterHotKey(hwnd, id uintptr) (bool, error) {
ret, _, err := unregisterHotkey.Call(hwnd, id)
return ret != 0, err
}
// MSG contains message information from a thread's message queue.
//
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-msg
type MSG struct {
HWnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Pt struct { //POINT
x, y int32
}
}
// SendMessage sends the specified message to a window or windows.
// The SendMessage function calls the window procedure for the specified
// window and does not return until the window procedure has processed
// the message.
// The return value specifies the result of the message processing;
// it depends on the message sent.
//
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessage
func SendMessage(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
ret, _, _ := sendMessage.Call(
hwnd,
uintptr(msg),
wParam,
lParam,
)
return ret
}
// GetMessage retrieves a message from the calling thread's message
// queue. The function dispatches incoming sent messages until a posted
// message is available for retrieval.
//
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage
func GetMessage(msg *MSG, hWnd uintptr, msgFilterMin, msgFilterMax uint32) bool {
ret, _, _ := getMessage.Call(
uintptr(unsafe.Pointer(msg)),
hWnd,
uintptr(msgFilterMin),
uintptr(msgFilterMax),
)
return ret != 0
}
// PeekMessage dispatches incoming sent messages, checks the thread message
// queue for a posted message, and retrieves the message (if any exist).
//
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-peekmessagea
func PeekMessage(msg *MSG, hWnd uintptr, msgFilterMin, msgFilterMax uint32) bool {
ret, _, _ := peekMessage.Call(
uintptr(unsafe.Pointer(msg)),
hWnd,
uintptr(msgFilterMin),
uintptr(msgFilterMax),
0, // PM_NOREMOVE
)
return ret != 0
}
// PostQuitMessage indicates to the system that a thread has made
// a request to terminate (quit). It is typically used in response
// to a WM_DESTROY message.
//
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-postquitmessage
func PostQuitMessage(exitCode int) {
quitMessage.Call(uintptr(exitCode))
}
func GetAsyncKeyState(keycode int) uintptr {
ret, _, _ := getAsyncKeyState.Call(uintptr(keycode))
return ret
}

View File

@@ -0,0 +1,194 @@
//go:build linux
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <X11/Xlib.h>
#include <X11/Xutil.h>
extern void hotkeyDown(uintptr_t hkhandle);
extern void hotkeyUp(uintptr_t hkhandle);
// Global error flag for BadAccess detection
static int grab_error_occurred = 0;
// Error handler for X11 errors
static int handleXError(Display* dpy, XErrorEvent* pErr) {
// Check if it's a GrabKey error (request_code == 33)
if (pErr->request_code == 33) {
// BadAccess means the key is already grabbed by another client
if (pErr->error_code == BadAccess) {
grab_error_occurred = 1;
return 0;
}
}
// For other errors, use default handler
return 0;
}
// displayTest checks if X11 display is available
int displayTest() {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
XCloseDisplay(d);
return 0;
}
return -1;
}
// DisplayContext represents a persistent X11 display connection
typedef struct {
Display* display;
int keycode;
unsigned int mod;
Window root;
} DisplayContext;
// openDisplay opens and initializes a display context
// Returns NULL on failure
DisplayContext* openDisplay(unsigned int mod, int key) {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d != NULL) break;
}
if (d == NULL) {
return NULL;
}
DisplayContext* ctx = (DisplayContext*)malloc(sizeof(DisplayContext));
if (ctx == NULL) {
XCloseDisplay(d);
return NULL;
}
ctx->display = d;
ctx->keycode = XKeysymToKeycode(d, key);
ctx->mod = mod;
ctx->root = DefaultRootWindow(d);
return ctx;
}
// closeDisplay closes the display and frees the context
void closeDisplay(DisplayContext* ctx) {
if (ctx == NULL) return;
if (ctx->display != NULL) {
XCloseDisplay(ctx->display);
}
free(ctx);
}
// grabHotkey attempts to grab the hotkey with error handling
// Returns: 0 on success, -1 on BadAccess (conflict), -2 on other errors
int grabHotkey(DisplayContext* ctx) {
if (ctx == NULL || ctx->display == NULL) {
return -2;
}
// Set custom error handler
grab_error_occurred = 0;
XErrorHandler old_handler = XSetErrorHandler(handleXError);
// Attempt to grab the key
XGrabKey(ctx->display, ctx->keycode, ctx->mod, ctx->root,
False, GrabModeAsync, GrabModeAsync);
// Flush to ensure the grab request is processed
XSync(ctx->display, False);
// Restore old error handler
XSetErrorHandler(old_handler);
// Check if error occurred
if (grab_error_occurred) {
return -1; // BadAccess - hotkey conflict
}
// Select input for both KeyPress and KeyRelease
XSelectInput(ctx->display, ctx->root, KeyPressMask | KeyReleaseMask);
return 0;
}
// ungrabHotkey releases the grabbed hotkey
void ungrabHotkey(DisplayContext* ctx) {
if (ctx == NULL || ctx->display == NULL) return;
XUngrabKey(ctx->display, ctx->keycode, ctx->mod, ctx->root);
XFlush(ctx->display);
}
// registerHotkey attempts to register the hotkey and returns a display context
// Returns: context pointer on success, NULL on failure
// Sets *error_code: 0 = success, -1 = BadAccess (conflict), -2 = other error
DisplayContext* registerHotkey(unsigned int mod, int key, int* error_code) {
DisplayContext* ctx = openDisplay(mod, key);
if (ctx == NULL) {
*error_code = -2;
return NULL;
}
int grab_result = grabHotkey(ctx);
if (grab_result != 0) {
*error_code = grab_result;
closeDisplay(ctx);
return NULL;
}
*error_code = 0;
return ctx;
}
// unregisterHotkey ungrab the hotkey and closes the display
void unregisterHotkey(DisplayContext* ctx) {
if (ctx == NULL) return;
ungrabHotkey(ctx);
closeDisplay(ctx);
}
// waitHotkeyEvent waits for the next hotkey event on an already-registered hotkey
// Returns: 1 for KeyPress, 2 for KeyRelease, 0 for other events, -1 on error
int waitHotkeyEvent(DisplayContext* ctx, uintptr_t hkhandle) {
if (ctx == NULL || ctx->display == NULL) {
return -1;
}
XEvent ev;
XNextEvent(ctx->display, &ev);
switch(ev.type) {
case KeyPress:
hotkeyDown(hkhandle);
return 1;
case KeyRelease:
hotkeyUp(hkhandle);
return 2;
default:
return 0;
}
}
// Legacy waitHotkey for compatibility - now uses the new API internally
// Returns: 0 on KeyRelease, -1 on BadAccess (conflict), -2 on other error
int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
int error_code = 0;
DisplayContext* ctx = registerHotkey(mod, key, &error_code);
if (ctx == NULL) {
return error_code;
}
// Event loop
while(1) {
int result = waitHotkeyEvent(ctx, hkhandle);
if (result == 2) { // KeyRelease
unregisterHotkey(ctx);
return 0;
}
if (result < 0) { // Error
unregisterHotkey(ctx);
return -2;
}
}
}

View File

@@ -0,0 +1,275 @@
//go:build linux
package linux
/*
#cgo LDFLAGS: -lX11
#include <stdint.h>
#include <stdlib.h>
// DisplayContext is defined in hotkey.c
typedef struct DisplayContext DisplayContext;
int displayTest();
DisplayContext* registerHotkey(unsigned int mod, int key, int* error_code);
void unregisterHotkey(DisplayContext* ctx);
int waitHotkeyEvent(DisplayContext* ctx, uintptr_t hkhandle);
*/
import "C"
import (
"context"
"errors"
"fmt"
"runtime"
"runtime/cgo"
"sync"
)
const errmsg = `Failed to initialize the X11 display. Install the following dependency may help:
apt install -y libx11-dev
If you are in an environment without a frame buffer (e.g., cloud server),
you may also need to install xvfb:
apt install -y xvfb
and initialize a virtual frame buffer:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0
`
var x11Available bool
func init() {
x11Available = C.displayTest() == 0
}
func checkX11() error {
if !x11Available {
return fmt.Errorf("%w: %s", ErrPlatformUnavailable, errmsg)
}
return nil
}
// Standard errors
var (
ErrAlreadyRegistered = errors.New("hotkey: already registered")
ErrNotRegistered = errors.New("hotkey: not registered")
ErrPlatformUnavailable = errors.New("hotkey: platform support unavailable")
ErrHotkeyConflict = errors.New("hotkey: hotkey conflict with other applications")
ErrFailedToRegister = errors.New("hotkey: failed to register")
)
type PlatformHotkey struct {
mu sync.Mutex
registered bool
ctx context.Context
cancel context.CancelFunc
canceled chan struct{}
cgoHandle cgo.Handle // 改名避免与方法名冲突
displayCtx *C.DisplayContext // Persistent X11 display connection
}
func (ph *PlatformHotkey) Register(mods []Modifier, key Key, keydownIn, keyupIn chan<- interface{}) error {
// Check X11 availability first
if err := checkX11(); err != nil {
return err
}
ph.mu.Lock()
if ph.registered {
ph.mu.Unlock()
return ErrAlreadyRegistered
}
// Calculate combined modifier
var mod Modifier
for _, m := range mods {
mod = mod | m
}
// Try to register the hotkey and check for conflicts
var errorCode C.int
displayCtx := C.registerHotkey(C.uint(mod), C.int(key), &errorCode)
if displayCtx == nil {
ph.mu.Unlock()
switch errorCode {
case -1:
return fmt.Errorf("%w: key combination already grabbed by another application", ErrHotkeyConflict)
default:
return fmt.Errorf("%w: failed to open X11 display or register hotkey", ErrFailedToRegister)
}
}
ph.registered = true
ph.displayCtx = displayCtx
ph.ctx, ph.cancel = context.WithCancel(context.Background())
ph.canceled = make(chan struct{})
// Store callbacks info
callbacks := &callbackData{
keydownIn: keydownIn,
keyupIn: keyupIn,
}
ph.cgoHandle = cgo.NewHandle(callbacks)
ph.mu.Unlock()
go ph.eventLoop()
return nil
}
func (ph *PlatformHotkey) Unregister() error {
ph.mu.Lock()
defer ph.mu.Unlock()
if !ph.registered {
return ErrNotRegistered
}
ph.cancel()
ph.registered = false
<-ph.canceled
// Clean up CGO handle and X11 display
ph.cgoHandle.Delete()
if ph.displayCtx != nil {
C.unregisterHotkey(ph.displayCtx)
ph.displayCtx = nil
}
return nil
}
// eventLoop continuously waits for hotkey events on the registered display.
// The display connection is kept open for the lifetime of the registration,
// avoiding repeated XOpenDisplay/XCloseDisplay calls.
func (ph *PlatformHotkey) eventLoop() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for {
select {
case <-ph.ctx.Done():
close(ph.canceled)
return
default:
// Wait for the next event on the persistent display connection
result := C.waitHotkeyEvent(ph.displayCtx, C.uintptr_t(ph.cgoHandle))
// result: 1 = KeyPress, 2 = KeyRelease, 0 = other event, -1 = error
if result < 0 {
// Error occurred, likely display connection issue
// The context will be canceled by Unregister, so we'll exit
continue
}
// Events are handled by the C callback (hotkeyDown/hotkeyUp)
}
}
}
type callbackData struct {
keydownIn chan<- interface{}
keyupIn chan<- interface{}
}
//export hotkeyDown
func hotkeyDown(h uintptr) {
cb := cgo.Handle(h).Value().(*callbackData)
cb.keydownIn <- struct{}{}
}
//export hotkeyUp
func hotkeyUp(h uintptr) {
cb := cgo.Handle(h).Value().(*callbackData)
cb.keyupIn <- struct{}{}
}
// Modifier represents a modifier.
type Modifier uint32
// All kinds of Modifiers
// See /usr/include/X11/X.h
const (
ModCtrl Modifier = (1 << 2)
ModShift Modifier = (1 << 0)
Mod1 Modifier = (1 << 3)
Mod2 Modifier = (1 << 4)
Mod3 Modifier = (1 << 5)
Mod4 Modifier = (1 << 6)
Mod5 Modifier = (1 << 7)
// ModAlt is typically mapped to Mod1 on most Linux systems
ModAlt = Mod1
)
// Key represents a key.
// See /usr/include/X11/keysymdef.h
type Key uint16
// All kinds of keys
const (
KeySpace Key = 0x0020
Key1 Key = 0x0030
Key2 Key = 0x0031
Key3 Key = 0x0032
Key4 Key = 0x0033
Key5 Key = 0x0034
Key6 Key = 0x0035
Key7 Key = 0x0036
Key8 Key = 0x0037
Key9 Key = 0x0038
Key0 Key = 0x0039
KeyA Key = 0x0061
KeyB Key = 0x0062
KeyC Key = 0x0063
KeyD Key = 0x0064
KeyE Key = 0x0065
KeyF Key = 0x0066
KeyG Key = 0x0067
KeyH Key = 0x0068
KeyI Key = 0x0069
KeyJ Key = 0x006a
KeyK Key = 0x006b
KeyL Key = 0x006c
KeyM Key = 0x006d
KeyN Key = 0x006e
KeyO Key = 0x006f
KeyP Key = 0x0070
KeyQ Key = 0x0071
KeyR Key = 0x0072
KeyS Key = 0x0073
KeyT Key = 0x0074
KeyU Key = 0x0075
KeyV Key = 0x0076
KeyW Key = 0x0077
KeyX Key = 0x0078
KeyY Key = 0x0079
KeyZ Key = 0x007a
KeyReturn Key = 0xff0d
KeyEscape Key = 0xff1b
KeyDelete Key = 0xffff
KeyTab Key = 0xff1b
KeyLeft Key = 0xff51
KeyRight Key = 0xff53
KeyUp Key = 0xff52
KeyDown Key = 0xff54
KeyF1 Key = 0xffbe
KeyF2 Key = 0xffbf
KeyF3 Key = 0xffc0
KeyF4 Key = 0xffc1
KeyF5 Key = 0xffc2
KeyF6 Key = 0xffc3
KeyF7 Key = 0xffc4
KeyF8 Key = 0xffc5
KeyF9 Key = 0xffc6
KeyF10 Key = 0xffc7
KeyF11 Key = 0xffc8
KeyF12 Key = 0xffc9
KeyF13 Key = 0xffca
KeyF14 Key = 0xffcb
KeyF15 Key = 0xffcc
KeyF16 Key = 0xffcd
KeyF17 Key = 0xffce
KeyF18 Key = 0xffcf
KeyF19 Key = 0xffd0
KeyF20 Key = 0xffd1
)

View File

@@ -1,12 +1,6 @@
// Copyright 2022 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux
//go:build windows || linux
package mainthread
package linux
import (
"fmt"
@@ -14,10 +8,6 @@ import (
"sync"
)
func init() {
runtime.LockOSThread()
}
// Call calls f on the main thread and blocks until f finishes.
func Call(f func()) {
done := donePool.Get().(chan error)

View File

@@ -1,11 +0,0 @@
// Copyright 2022 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
// Package mainthread provides facilities to schedule functions
// on the main thread. It includes platform-specific implementations
// for Windows, Linux, and macOS. The macOS implementation is specially
// designed to handle main thread events for the NSApplication.
package mainthread

View File

@@ -1,28 +0,0 @@
// Copyright 2022 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
#include <stdint.h>
#import <Cocoa/Cocoa.h>
extern void dispatchMainFuncs();
void wakeupMainThread(void) {
dispatch_async(dispatch_get_main_queue(), ^{
dispatchMainFuncs();
});
}
// The following three lines of code must run on the main thread.
// It must handle it using golang.design/x/mainthread.
//
// inspired from here: https://github.com/cehoffman/dotfiles/blob/4be8e893517e970d40746a9bdc67fe5832dd1c33/os/mac/iTerm2HotKey.m
void os_main(void) {
[NSApplication sharedApplication];
[NSApp disableRelaunchOnLogin];
[NSApp run];
}

View File

@@ -0,0 +1,281 @@
//go:build windows
package windows
import (
"errors"
"fmt"
"runtime"
"sync"
"sync/atomic"
"syscall"
"time"
"unsafe"
)
// Standard errors
var (
ErrAlreadyRegistered = errors.New("hotkey: already registered")
ErrNotRegistered = errors.New("hotkey: not registered")
ErrFailedToRegister = errors.New("hotkey: failed to register")
)
type PlatformHotkey struct {
mu sync.Mutex
hotkeyId uint64
registered bool
funcs chan func()
canceled chan struct{}
}
var hotkeyId uint64 // atomic
// Register registers a system hotkey. It returns an error if
// the registration is failed. This could be that the hotkey is
// conflict with other hotkeys.
func (ph *PlatformHotkey) Register(mods []Modifier, key Key, keydownIn, keyupIn chan<- interface{}) error {
ph.mu.Lock()
if ph.registered {
ph.mu.Unlock()
return ErrAlreadyRegistered
}
mod := uint8(0)
for _, m := range mods {
mod = mod | uint8(m)
}
ph.hotkeyId = atomic.AddUint64(&hotkeyId, 1)
ph.funcs = make(chan func())
ph.canceled = make(chan struct{})
go ph.handle(key, keydownIn, keyupIn)
var (
ok bool
err error
done = make(chan struct{})
)
ph.funcs <- func() {
ok, err = RegisterHotKey(0, uintptr(ph.hotkeyId), uintptr(mod), uintptr(key))
done <- struct{}{}
}
<-done
if !ok {
close(ph.canceled)
ph.mu.Unlock()
return fmt.Errorf("%w: %v", ErrFailedToRegister, err)
}
ph.registered = true
ph.mu.Unlock()
return nil
}
// Unregister deregisteres a system hotkey.
func (ph *PlatformHotkey) Unregister() error {
ph.mu.Lock()
defer ph.mu.Unlock()
if !ph.registered {
return ErrNotRegistered
}
done := make(chan struct{})
ph.funcs <- func() {
UnregisterHotKey(0, uintptr(ph.hotkeyId))
done <- struct{}{}
close(ph.canceled)
}
<-done
<-ph.canceled
ph.registered = false
return nil
}
const (
// wmHotkey represents hotkey message
wmHotkey uint32 = 0x0312
wmQuit uint32 = 0x0012
)
// handle handles the hotkey event loop.
// Simple, reliable approach with proper state management to prevent duplicate triggers
func (ph *PlatformHotkey) handle(key Key, keydownIn, keyupIn chan<- interface{}) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
isKeyDown := false
ticker := time.NewTicker(time.Millisecond * 10)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Process all pending messages
msg := MSG{}
for PeekMessage(&msg, 0, 0, 0) {
if msg.Message == wmHotkey {
// Only trigger if not already down (防止重复触发)
if !isKeyDown {
keydownIn <- struct{}{}
isKeyDown = true
}
} else if msg.Message == wmQuit {
return
}
}
// Check for key release when key is down
if isKeyDown && GetAsyncKeyState(int(key)) == 0 {
keyupIn <- struct{}{}
isKeyDown = false
}
case f := <-ph.funcs:
f()
case <-ph.canceled:
return
}
}
}
// Modifier represents a modifier.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey
type Modifier uint8
// All kinds of Modifiers
const (
ModAlt Modifier = 0x1
ModCtrl Modifier = 0x2
ModShift Modifier = 0x4
ModWin Modifier = 0x8
)
// Key represents a key.
// https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
type Key uint16
// All kinds of Keys
const (
KeySpace Key = 0x20
Key0 Key = 0x30
Key1 Key = 0x31
Key2 Key = 0x32
Key3 Key = 0x33
Key4 Key = 0x34
Key5 Key = 0x35
Key6 Key = 0x36
Key7 Key = 0x37
Key8 Key = 0x38
Key9 Key = 0x39
KeyA Key = 0x41
KeyB Key = 0x42
KeyC Key = 0x43
KeyD Key = 0x44
KeyE Key = 0x45
KeyF Key = 0x46
KeyG Key = 0x47
KeyH Key = 0x48
KeyI Key = 0x49
KeyJ Key = 0x4A
KeyK Key = 0x4B
KeyL Key = 0x4C
KeyM Key = 0x4D
KeyN Key = 0x4E
KeyO Key = 0x4F
KeyP Key = 0x50
KeyQ Key = 0x51
KeyR Key = 0x52
KeyS Key = 0x53
KeyT Key = 0x54
KeyU Key = 0x55
KeyV Key = 0x56
KeyW Key = 0x57
KeyX Key = 0x58
KeyY Key = 0x59
KeyZ Key = 0x5A
KeyReturn Key = 0x0D
KeyEscape Key = 0x1B
KeyDelete Key = 0x2E
KeyTab Key = 0x09
KeyLeft Key = 0x25
KeyRight Key = 0x27
KeyUp Key = 0x26
KeyDown Key = 0x28
KeyF1 Key = 0x70
KeyF2 Key = 0x71
KeyF3 Key = 0x72
KeyF4 Key = 0x73
KeyF5 Key = 0x74
KeyF6 Key = 0x75
KeyF7 Key = 0x76
KeyF8 Key = 0x77
KeyF9 Key = 0x78
KeyF10 Key = 0x79
KeyF11 Key = 0x7A
KeyF12 Key = 0x7B
KeyF13 Key = 0x7C
KeyF14 Key = 0x7D
KeyF15 Key = 0x7E
KeyF16 Key = 0x7F
KeyF17 Key = 0x80
KeyF18 Key = 0x81
KeyF19 Key = 0x82
KeyF20 Key = 0x83
)
// Windows API wrappers
var (
user32 = syscall.NewLazyDLL("user32")
registerHotkey = user32.NewProc("RegisterHotKey")
unregisterHotkey = user32.NewProc("UnregisterHotKey")
peekMessage = user32.NewProc("PeekMessageW")
getAsyncKeyState = user32.NewProc("GetAsyncKeyState")
)
// RegisterHotKey defines a system-wide hot key.
func RegisterHotKey(hwnd, id uintptr, mod uintptr, k uintptr) (bool, error) {
ret, _, err := registerHotkey.Call(hwnd, id, mod, k)
return ret != 0, err
}
// UnregisterHotKey frees a hot key previously registered by the calling thread.
func UnregisterHotKey(hwnd, id uintptr) (bool, error) {
ret, _, err := unregisterHotkey.Call(hwnd, id)
return ret != 0, err
}
// MSG contains message information from a thread's message queue.
type MSG struct {
HWnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Pt struct {
x, y int32
}
}
// PeekMessage checks for messages without blocking and removes them from queue
func PeekMessage(msg *MSG, hWnd uintptr, msgFilterMin, msgFilterMax uint32) bool {
const PM_REMOVE = 0x0001
ret, _, _ := peekMessage.Call(
uintptr(unsafe.Pointer(msg)),
hWnd,
uintptr(msgFilterMin),
uintptr(msgFilterMax),
PM_REMOVE,
)
return ret != 0
}
// GetAsyncKeyState determines whether a key is up or down
func GetAsyncKeyState(keycode int) uintptr {
ret, _, _ := getAsyncKeyState.Call(uintptr(keycode))
return ret
}

View File

@@ -0,0 +1,77 @@
//go:build windows
package windows
import (
"fmt"
"runtime"
"sync"
)
// Call calls f on the main thread and blocks until f finishes.
func Call(f func()) {
done := donePool.Get().(chan error)
defer donePool.Put(done)
data := funcData{fn: f, done: done}
funcQ <- data
if err := <-done; err != nil {
panic(err)
}
}
// Init initializes the functionality of running arbitrary subsequent functions be called on the main system thread.
//
// Init must be called in the main.main function.
func Init(main func()) {
done := donePool.Get().(chan error)
defer donePool.Put(done)
go func() {
defer func() {
done <- nil
}()
main()
}()
for {
select {
case f := <-funcQ:
func() {
defer func() {
r := recover()
if f.done != nil {
if r != nil {
f.done <- fmt.Errorf("%v", r)
} else {
f.done <- nil
}
} else {
if r != nil {
select {
case erroQ <- fmt.Errorf("%v", r):
default:
}
}
}
}()
f.fn()
}()
case <-done:
return
}
}
}
var (
funcQ = make(chan funcData, runtime.GOMAXPROCS(0))
erroQ = make(chan error, 42)
donePool = sync.Pool{New: func() interface{} {
return make(chan error)
}}
)
type funcData struct {
fn func()
done chan error
}

View File

@@ -6,8 +6,8 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"voidraft/internal/common/hotkey"
"voidraft/internal/common/hotkey/mainthread"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -28,6 +28,10 @@ type HotkeyService struct {
hk *hotkey.Hotkey
stopChan chan struct{}
wg sync.WaitGroup
// 防抖相关
lastTriggerTime atomic.Int64 // 上次触发时间Unix 纳秒)
debounceInterval time.Duration // 防抖间隔
}
// NewHotkeyService 创建热键服务实例
@@ -37,10 +41,11 @@ func NewHotkeyService(configService *ConfigService, logger *log.LogService) *Hot
}
return &HotkeyService{
logger: logger,
configService: configService,
windowHelper: NewWindowHelper(),
stopChan: make(chan struct{}),
logger: logger,
configService: configService,
windowHelper: NewWindowHelper(),
stopChan: make(chan struct{}),
debounceInterval: 100 * time.Millisecond,
}
}
@@ -72,31 +77,28 @@ func (hs *HotkeyService) RegisterHotkey(combo *models.HotkeyCombo) error {
return errors.New("invalid hotkey combination")
}
hs.mu.Lock()
defer hs.mu.Unlock()
// 如果已注册,先取消
if hs.registered.Load() {
hs.unregisterInternal()
}
// 转换为 hotkey 库的格式
key, mods, err := hs.convertHotkey(combo)
if err != nil {
return fmt.Errorf("convert hotkey: %w", err)
}
// 在主线程中创建热键
var regErr error
mainthread.Init(func() {
hs.hk = hotkey.New(mods, key)
regErr = hs.hk.Register()
})
if regErr != nil {
return fmt.Errorf("register hotkey: %w", regErr)
// 创建热键(在锁外创建,避免持锁时间过长)
newHk := hotkey.New(mods, key)
if err := newHk.Register(); err != nil {
return fmt.Errorf("register hotkey: %w", err)
}
hs.mu.Lock()
defer hs.mu.Unlock()
// 如果已注册,先取消旧热键
if hs.registered.Load() {
hs.unregisterInternal()
}
// 设置新热键
hs.hk = newHk
hs.registered.Store(true)
hs.currentHotkey = combo
@@ -155,11 +157,25 @@ func (hs *HotkeyService) UpdateHotkey(enable bool, combo *models.HotkeyCombo) er
func (hs *HotkeyService) listenHotkey() {
defer hs.wg.Done()
// 缓存 channel 引用,避免每次循环都访问 hs.hk
hs.mu.RLock()
keydownChan := hs.hk.Keydown()
hs.mu.RUnlock()
for {
select {
case <-hs.stopChan:
return
case <-hs.hk.Keydown():
case <-keydownChan:
now := time.Now().UnixNano()
lastTrigger := hs.lastTriggerTime.Load()
// 如果距离上次触发时间小于防抖间隔,忽略此次触发
if lastTrigger > 0 && time.Duration(now-lastTrigger) < hs.debounceInterval {
continue
}
// 更新最后触发时间
hs.lastTriggerTime.Store(now)
hs.toggleWindow()
}
}

View File

@@ -0,0 +1,387 @@
package services
import (
"testing"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// TestHotkeyServiceCreation 测试服务创建
func TestHotkeyServiceCreation(t *testing.T) {
logger := log.New()
configService := &ConfigService{} // Mock
service := NewHotkeyService(configService, logger)
if service == nil {
t.Fatal("Failed to create hotkey service")
}
if service.logger == nil {
t.Error("Logger should not be nil")
}
if service.registered.Load() {
t.Error("Service should not have registered hotkey initially")
}
}
// TestHotkeyValidation 测试热键验证
func TestHotkeyValidation(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
tests := []struct {
name string
combo *models.HotkeyCombo
valid bool
}{
{
name: "Nil combo",
combo: nil,
valid: false,
},
{
name: "Empty key",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "",
},
valid: false,
},
{
name: "No modifiers",
combo: &models.HotkeyCombo{
Key: "A",
},
valid: false,
},
{
name: "Valid: Ctrl+A",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "A",
},
valid: true,
},
{
name: "Valid: Ctrl+Shift+F1",
combo: &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Key: "F1",
},
valid: true,
},
{
name: "Valid: Alt+Space",
combo: &models.HotkeyCombo{
Alt: true,
Key: "Space",
},
valid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.isValidHotkey(tt.combo)
if result != tt.valid {
t.Errorf("Expected valid=%v, got %v", tt.valid, result)
}
})
}
}
// TestHotkeyConversion 测试热键转换
func TestHotkeyConversion(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
tests := []struct {
name string
combo *models.HotkeyCombo
wantErr bool
}{
{
name: "Valid letter key",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "A",
},
wantErr: false,
},
{
name: "Valid number key",
combo: &models.HotkeyCombo{
Shift: true,
Key: "1",
},
wantErr: false,
},
{
name: "Valid function key",
combo: &models.HotkeyCombo{
Alt: true,
Key: "F5",
},
wantErr: false,
},
{
name: "Invalid key",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "InvalidKey",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, mods, err := service.convertHotkey(tt.combo)
if tt.wantErr {
if err == nil {
t.Error("Expected error, got nil")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if key == 0 {
t.Error("Key should not be 0")
}
if len(mods) == 0 {
t.Error("Should have at least one modifier")
}
})
}
}
// TestHotkeyRegisterUnregister 测试注册和注销
func TestHotkeyRegisterUnregister(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Key: "F10",
}
// 测试注册
err := service.RegisterHotkey(combo)
if err != nil {
t.Logf("Register failed (may be expected in test environment): %v", err)
return
}
if !service.IsRegistered() {
t.Error("Service should be registered")
}
// 验证当前热键
current := service.GetCurrentHotkey()
if current == nil {
t.Error("Current hotkey should not be nil")
}
if current.Key != combo.Key {
t.Errorf("Expected key %s, got %s", combo.Key, current.Key)
}
// 测试注销
err = service.UnregisterHotkey()
if err != nil {
t.Fatalf("Unregister failed: %v", err)
}
if service.IsRegistered() {
t.Error("Service should not be registered after unregister")
}
current = service.GetCurrentHotkey()
if current != nil {
t.Error("Current hotkey should be nil after unregister")
}
}
// TestHotkeyUpdate 测试更新热键
func TestHotkeyUpdate(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo1 := &models.HotkeyCombo{
Ctrl: true,
Key: "F11",
}
// 启用热键
err := service.UpdateHotkey(true, combo1)
if err != nil {
t.Logf("Update (enable) failed: %v", err)
return
}
defer service.UnregisterHotkey()
if !service.IsRegistered() {
t.Error("Should be registered after enable")
}
// 禁用热键
err = service.UpdateHotkey(false, combo1)
if err != nil {
t.Fatalf("Update (disable) failed: %v", err)
}
if service.IsRegistered() {
t.Error("Should not be registered after disable")
}
}
// TestHotkeyDoubleRegister 测试重复注册
func TestHotkeyDoubleRegister(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Alt: true,
Key: "F12",
}
err := service.RegisterHotkey(combo)
if err != nil {
t.Skip("First registration failed")
}
defer service.UnregisterHotkey()
// 第二次注册应该先取消第一次注册,然后重新注册
combo2 := &models.HotkeyCombo{
Shift: true,
Key: "F12",
}
err = service.RegisterHotkey(combo2)
if err != nil {
t.Logf("Second registration failed: %v", err)
}
// 验证当前热键是新的
current := service.GetCurrentHotkey()
if current != nil && current.Shift != combo2.Shift {
t.Error("Should have updated to new hotkey")
}
}
// TestHotkeyConcurrentAccess 测试并发访问
func TestHotkeyConcurrentAccess(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Key: "G",
}
const goroutines = 10
done := make(chan bool, goroutines)
// 并发读取
for i := 0; i < goroutines; i++ {
go func() {
for j := 0; j < 100; j++ {
_ = service.IsRegistered()
_ = service.GetCurrentHotkey()
time.Sleep(time.Millisecond)
}
done <- true
}()
}
// 主协程进行注册/注销操作
go func() {
for i := 0; i < 5; i++ {
service.RegisterHotkey(combo)
time.Sleep(50 * time.Millisecond)
service.UnregisterHotkey()
time.Sleep(50 * time.Millisecond)
}
}()
// 等待所有 goroutine 完成
for i := 0; i < goroutines; i++ {
<-done
}
t.Log("Concurrent access test completed without panics")
}
// TestHotkeyServiceShutdown 测试服务关闭
func TestHotkeyServiceShutdown(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Key: "H",
}
err := service.RegisterHotkey(combo)
if err != nil {
t.Skip("Registration failed")
}
// 测试 ServiceShutdown
err = service.ServiceShutdown()
if err != nil {
t.Fatalf("ServiceShutdown failed: %v", err)
}
if service.IsRegistered() {
t.Error("Should not be registered after shutdown")
}
}
// BenchmarkHotkeyRegistration 基准测试:热键注册
func BenchmarkHotkeyRegistration(b *testing.B) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Key: "B",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
service.RegisterHotkey(combo)
service.UnregisterHotkey()
}
}
// BenchmarkHotkeyConversion 基准测试:热键转换
func BenchmarkHotkeyConversion(b *testing.B) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Alt: true,
Key: "F5",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
service.convertHotkey(combo)
}
}