335 lines
8.3 KiB
Go
335 lines
8.3 KiB
Go
// 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.
|
|
//
|
|
// # Basic Usage
|
|
//
|
|
// hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS)
|
|
// if err := hk.Register(); err != nil {
|
|
// log.Fatal(err)
|
|
// }
|
|
// defer hk.Close()
|
|
//
|
|
// for {
|
|
// select {
|
|
// case <-hk.Keydown():
|
|
// fmt.Println("Hotkey pressed!")
|
|
// case <-hk.Keyup():
|
|
// fmt.Println("Hotkey released!")
|
|
// }
|
|
// }
|
|
//
|
|
// # 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
|
|
type Event struct{}
|
|
|
|
// Hotkey is a combination of modifiers and key to trigger an event
|
|
type Hotkey struct {
|
|
platformHotkey
|
|
|
|
mods []Modifier
|
|
key Key
|
|
|
|
keydownIn chan<- Event
|
|
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 {
|
|
hk := &Hotkey{
|
|
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.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
|
|
// 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 }
|
|
|
|
// 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. 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
|
|
}
|
|
|
|
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.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)
|
|
for _, mod := range hk.mods {
|
|
s += fmt.Sprintf("+%v", mod)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// newEventChan returns a sender and a receiver of a buffered channel
|
|
// with infinite capacity.
|
|
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 {
|
|
e, ok := <-in
|
|
if !ok {
|
|
close(out)
|
|
return
|
|
}
|
|
q = append(q, e)
|
|
for len(q) > 0 {
|
|
select {
|
|
case out <- q[0]:
|
|
q[0] = Event{}
|
|
q = q[1:]
|
|
case e, ok := <-in:
|
|
if ok {
|
|
q = append(q, e)
|
|
break
|
|
}
|
|
for _, e := range q {
|
|
out <- e
|
|
}
|
|
close(out)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return in, out
|
|
}
|