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

@@ -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 {