From 1d76324af6ca6b1fb0b4814fde7fac6b05cf7994 Mon Sep 17 00:00:00 2001 From: ouchangkun Date: Thu, 25 Feb 2021 22:39:11 +0100 Subject: [PATCH] all: initial implementation --- .github/FUNDING.yml | 12 ++++ .github/workflows/hotkey.yml | 48 +++++++++++++ README.md | 72 ++++++++++++++++++- example/main.go | 30 ++++++++ go.mod | 7 +- go.sum | 5 ++ hotkey.go | 130 +++++++++++++++++++++++++++++++++++ hotkey_darwin.go | 123 +++++++++++++++++++++++++++++++++ hotkey_darwin.m | 56 +++++++++++++++ hotkey_darwin_test.go | 72 +++++++++++++++++++ hotkey_linux.c | 64 +++++++++++++++++ hotkey_linux.go | 115 +++++++++++++++++++++++++++++++ hotkey_linux_test.go | 46 +++++++++++++ hotkey_test.go | 20 ++++++ hotkey_windows.go | 113 ++++++++++++++++++++++++++++++ hotkey_windows_test.go | 46 +++++++++++++ internal/cgo/handle.go | 103 +++++++++++++++++++++++++++ internal/cgo/handle_test.go | 105 ++++++++++++++++++++++++++++ internal/win/hotkey.go | 89 ++++++++++++++++++++++++ 19 files changed, 1253 insertions(+), 3 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/hotkey.yml create mode 100644 example/main.go create mode 100644 go.sum create mode 100644 hotkey.go create mode 100644 hotkey_darwin.go create mode 100644 hotkey_darwin.m create mode 100644 hotkey_darwin_test.go create mode 100644 hotkey_linux.c create mode 100644 hotkey_linux.go create mode 100644 hotkey_linux_test.go create mode 100644 hotkey_test.go create mode 100644 hotkey_windows.go create mode 100644 hotkey_windows_test.go create mode 100644 internal/cgo/handle.go create mode 100644 internal/cgo/handle_test.go create mode 100644 internal/win/hotkey.go diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..30bf190 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [changkun] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] \ No newline at end of file diff --git a/.github/workflows/hotkey.yml b/.github/workflows/hotkey.yml new file mode 100644 index 0000000..28cf741 --- /dev/null +++ b/.github/workflows/hotkey.yml @@ -0,0 +1,48 @@ +# 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 + +name: hotkey + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + platform_test: + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + + - name: install xvfb libx11-dev + run: | + sudo apt update + sudo apt install -y xvfb libx11-dev + if: ${{ runner.os == 'Linux' }} + + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + stable: 'false' + go-version: '1.16.0' + + - name: TestLinux + run: | + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + export DISPLAY=:99.0 + sleep 5s + go test -v -covermode=atomic ./... + if: ${{ runner.os == 'Linux' }} + + - name: TestOthers + run: | + go test -v -covermode=atomic ./... \ No newline at end of file diff --git a/README.md b/README.md index 91909ae..44204dc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ -# hotkey -cross platform hotkey package +# hotkey [![PkgGoDev](https://pkg.go.dev/badge/golang.design/x/hotkey)](https://pkg.go.dev/golang.design/x/hotkey) ![](https://changkun.de/urlstat?mode=github&repo=golang-design/hotkey) ![hotkey](https://github.com/golang-design/hotkey/workflows/hotkey/badge.svg?branch=main) + +cross platform hotkey package in Go + +```go +import "golang.design/x/hotkey" +``` + +## Features + +- Cross platform supports: macOS, Linux (X11), and Windows +- Global hotkey registration without focus on a window + +## API Usage + +Package `hotkey` provides the basic facility to register a system-level +hotkey so that the application can be notified if a user triggers the +desired hotkey. By definition, a hotkey is a combination of modifiers +and a single key, and thus register a hotkey that contains multiple +keys is not supported at the moment. Furthermore, because of OS +restriction, hotkey events must be handled on the main thread. + +Therefore, in order to use this package properly, here is a complete +example that corporates the [mainthread](https://golang.design/s/mainthread) +package: + +```go +package main + +import ( + "context" + + "golang.design/x/hotkey" + "golang.design/x/mainthread" +) + +// initialize mainthread facility so that hotkey can be +// properly registered to the system and handled by the +// application. +func main() { mainthread.Init(fn) } +func fn() { // Use fn as the actual main function. + var ( + mods = []hotkey.Modifier{hotkey.ModCtrl} + k = hotkey.KeyS + ) + + // Register a desired hotkey. + hk, err := hotkey.Register(mods, k) + if err != nil { + panic("hotkey registration failed") + } + + // Start listen hotkey event whenever you feel it is ready. + triggered := hk.Listen(context.Background()) + for range triggered { + println("hotkey ctrl+s is triggered") + } +} +``` + +## Who is using this package? + +The main purpose of building this package is to support the +[midgard](https://changkun.de/s/midgard) project. + +To know more projects, check our [wiki](https://github.com/golang-design/clipboard/wiki) page. + +## License + +MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de). \ No newline at end of file diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..bbe3771 --- /dev/null +++ b/example/main.go @@ -0,0 +1,30 @@ +// 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 main + +import ( + "context" + + "golang.design/x/hotkey" + "golang.design/x/mainthread" +) + +func main() { mainthread.Init(fn) } +func fn() { + var ( + mods = []hotkey.Modifier{hotkey.ModCtrl} + k = hotkey.KeyS + ) + + hk, err := hotkey.Register(mods, k) + if err != nil { + panic("hotkey registration failed") + } + + triggered := hk.Listen(context.Background()) + for range triggered { + println("hotkey ctrl+s is triggered") + } +} diff --git a/go.mod b/go.mod index a27f8c1..e4f28a2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module golang.design/x/hotkey -go 1.16 \ No newline at end of file +go 1.16 + +require ( + golang.design/x/mainthread v0.2.1 + golang.org/x/sys v0.0.0-20210122093101-04d7465088b8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2d810d3 --- /dev/null +++ b/go.sum @@ -0,0 +1,5 @@ +golang.design/x/mainthread v0.2.1 h1:IUGVW1acDfKoQtFeeS/RD/YYiKK8jxwkJXIQuKuL+ig= +golang.design/x/mainthread v0.2.1/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvHeu259L0= +golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210122093101-04d7465088b8 h1:de2yTH1xuxjmGB7i6Z5o2z3RCHVa0XlpSZzjd8Fe6bE= +golang.org/x/sys v0.0.0-20210122093101-04d7465088b8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/hotkey.go b/hotkey.go new file mode 100644 index 0000000..e5849f5 --- /dev/null +++ b/hotkey.go @@ -0,0 +1,130 @@ +// 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 + +// Package hotkey provides the basic facility to register a system-level +// hotkey so that the application can be notified if a user triggers the +// desired hotkey. By definition, a hotkey is a combination of modifiers +// and a single key, and thus register a hotkey that contains multiple +// keys is not supported at the moment. Furthermore, because of OS +// restriction, hotkey events must be handled on the main thread. +// +// Therefore, in order to use this package properly, here is a complete +// example that corporates the golang.design/x/mainthread package: +// +// package main +// +// import ( +// "context" +// +// "golang.design/x/hotkey" +// "golang.design/x/mainthread" +// ) +// +// // initialize mainthread facility so that hotkey can be +// // properly registered to the system and handled by the +// // application. +// func main() { mainthread.Init(fn) } +// func fn() { // Use fn as the actual main function. +// var ( +// mods = []hotkey.Modifier{hotkey.ModCtrl} +// k = hotkey.KeyS +// ) +// +// // Register a desired hotkey. +// hk, err := hotkey.Register(mods, k) +// if err != nil { +// panic("hotkey registration failed") +// } +// +// // Start listen hotkey event whenever you feel it is ready. +// triggered := hk.Listen(context.Background()) +// for range triggered { +// println("hotkey ctrl+s is triggered") +// } +// } +package hotkey + +import ( + "context" + "runtime" + + "golang.design/x/mainthread" +) + +// Event represents a hotkey event +type Event struct{} + +// Hotkey is a combination of modifiers and key to trigger an event +type Hotkey struct { + mods []Modifier + key Key + + in chan<- Event + out <-chan Event +} + +// Register registers a combination of hotkeys. If the hotkey has +// registered. This function will invalidates the old registration +// and overwrites its callback. +func Register(mods []Modifier, key Key) (*Hotkey, error) { + in, out := newEventChan() + hk := &Hotkey{mods, key, in, out} + + var err error + mainthread.Call(func() { err = hk.register() }) + if err != nil { + return nil, err + } + + runtime.SetFinalizer(hk, func(hk *Hotkey) { + hk.unregister() + }) + + return hk, nil +} + +// Listen handles a hotkey event and triggers a call to fn. +// The hotkey listen hook terminates when the context is canceled. +func (hk *Hotkey) Listen(ctx context.Context) <-chan Event { + mainthread.Go(func() { hk.handle(ctx) }) + return hk.out +} + +// newEventChan returns a sender and a receiver of a buffered channel +// with infinite capacity. +func newEventChan() (chan<- Event, <-chan Event) { + in, out := make(chan Event), make(chan Event) + + go func() { + 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 = 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 +} diff --git a/hotkey_darwin.go b/hotkey_darwin.go new file mode 100644 index 0000000..858f196 --- /dev/null +++ b/hotkey_darwin.go @@ -0,0 +1,123 @@ +// 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 + +//go:build darwin +// +build darwin + +package hotkey + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa -framework Carbon +#import +#import + +extern void hotkeyCallback(unsigned long long handle); + +int registerHotKey(int mod, int key, unsigned long long handle); +void runApp(); +void stopApp(); +*/ +import "C" +import ( + "context" + "errors" + + "golang.design/x/hotkey/internal/cgo" +) + +// handle handles the hotkey event loop. +func (hk *Hotkey) handle(ctx context.Context) { + // KNOWN ISSUE: This application never ends. + C.runApp() +} + +func (hk *Hotkey) register() error { + // KNOWN ISSUE: 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.in) + var mod Modifier + for _, m := range hk.mods { + mod += m + } + + ret := C.registerHotKey(C.int(mod), C.int(hk.key), C.ulonglong(h)) + if ret == C.int(-1) { + return errors.New("register failed") + } + return nil +} + +func (hk *Hotkey) unregister() { + // TODO: unregister registered hotkeys. + + // KNOWN ISSUE: This call seems does not terminate the app. + C.stopApp() +} + +//export hotkeyCallback +func hotkeyCallback(h C.ulonglong) { + ch := cgo.Handle(h).Value().(chan<- Event) + ch <- Event{} +} + +// 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 = 0x1000 + ModShift = 0x200 + ModOption = 0x800 + ModCmd = 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 ( + 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 +) diff --git a/hotkey_darwin.m b/hotkey_darwin.m new file mode 100644 index 0000000..6a09898 --- /dev/null +++ b/hotkey_darwin.m @@ -0,0 +1,56 @@ +// 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 + +//go:build darwin +// +build darwin + +#import +#import + +extern void hotkeyCallback(unsigned long long handle); + +static OSStatus +eventHandler(EventHandlerCallRef nextHandler, EventRef theEvent, void *userData) { + EventHotKeyID k; + GetEventParameter(theEvent, kEventParamDirectObject, typeEventHotKeyID, NULL, sizeof(k), NULL, &k); + hotkeyCallback((unsigned long long)k.id); // use id as handle + return noErr; +} + +// registerHotkeyWithCallback registers a global system hotkey for callbacks. +int registerHotKey(int mod, int key, unsigned long long handle) { + EventTypeSpec eventType; + eventType.eventClass = kEventClassKeyboard; + eventType.eventKind = kEventHotKeyPressed; + InstallApplicationEventHandler( + &eventHandler, 1, &eventType, NULL, NULL + ); + + EventHotKeyID hkid = {.id = handle}; + EventHotKeyRef ref; + OSStatus s = RegisterEventHotKey( + key, mod, hkid, GetApplicationEventTarget(), 0, &ref + ); + if (s != noErr) { + return -1; + } + return 0; +} + + +// 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 runApp() { + [NSApplication sharedApplication]; + [NSApp disableRelaunchOnLogin]; + [NSApp run]; +} + +void stopApp() { + [NSApp stop: nil]; +} \ No newline at end of file diff --git a/hotkey_darwin_test.go b/hotkey_darwin_test.go new file mode 100644 index 0000000..e968c6b --- /dev/null +++ b/hotkey_darwin_test.go @@ -0,0 +1,72 @@ +// 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 + +//go:build darwin +// +build darwin + +package hotkey_test + +import ( + "context" + "fmt" + "testing" + "time" + + "golang.design/x/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, err := hotkey.Register([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS) + if err != nil { + t.Errorf("failed to register hotkey: %v", err) + return + } + + trigger := hk.Listen(ctx) + for { + select { + case <-ctx.Done(): + cancel() + done <- struct{}{} + return + case <-trigger: + fmt.Println("triggered 1") + } + } + }() + + go func() { + hk, err := hotkey.Register([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModOption}, hotkey.KeyS) + if err != nil { + t.Errorf("failed to register hotkey: %v", err) + return + } + + trigger := hk.Listen(ctx) + for { + select { + case <-ctx.Done(): + cancel() + done <- struct{}{} + return + case <-trigger: + fmt.Println("triggered 2") + } + } + }() + + <-done + <-done +} diff --git a/hotkey_linux.c b/hotkey_linux.c new file mode 100644 index 0000000..7dde7a7 --- /dev/null +++ b/hotkey_linux.c @@ -0,0 +1,64 @@ +// 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 + +//go:build linux +// +build linux + +#include +#include +#include + +int displayTest() { + Display* d = XOpenDisplay(0); + if (d == NULL) { + return -1; + } + XCloseDisplay(d); + 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(unsigned int mod, int key) { + // FIXME: handle registered hotkey properly. + // XSetErrorHandler(handleErrors); + + Display* d = XOpenDisplay(0); + 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: + XUngrabKey(d, keycode, mod, DefaultRootWindow(d)); + XCloseDisplay(d); + return 0; + } + } +} \ No newline at end of file diff --git a/hotkey_linux.go b/hotkey_linux.go new file mode 100644 index 0000000..a67d3f2 --- /dev/null +++ b/hotkey_linux.go @@ -0,0 +1,115 @@ +// 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 + +//go:build linux +// +build linux + +package hotkey + +/* +#cgo LDFLAGS: -lX11 + +int displayTest(); +int waitHotkey(unsigned int mod, int key); +*/ +import "C" +import "context" + +func init() { + if C.displayTest() != 0 { + panic("cannot use hotkey package") + } +} + +// Nothing needs to do for register +func (hk *Hotkey) register() error { return nil } + +// Nothing needs to do for unregister +func (hk *Hotkey) unregister() {} + +// handle registers an application global hotkey to the system, +// and returns a channel that will signal if the hotkey is triggered. +// +// No customization for the hotkey, the hotkey is always: Ctrl+Mod4+s +func (hk *Hotkey) handle(ctx context.Context) { + // 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 + } + for { + select { + case <-ctx.Done(): + return + default: + ret := C.waitHotkey(C.uint(mod), C.int(hk.key)) + if ret != 0 { + continue + } + hk.in <- Event{} + } + } +} + +// Modifier represents a modifier. +type Modifier uint32 + +// All kinds of Modifiers +// See /usr/include/X11/X.h +const ( + ModCtrl = (1 << 2) + ModShift = (1 << 0) + Mod1 = (1 << 3) + Mod2 = (1 << 4) + Mod3 = (1 << 5) + Mod4 = (1 << 6) + Mod5 = (1 << 7) +) + +// Key represents a key. +// See /usr/include/X11/keysymdef.h +type Key uint8 + +// All kinds of keys +const ( + 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 +) diff --git a/hotkey_linux_test.go b/hotkey_linux_test.go new file mode 100644 index 0000000..4767922 --- /dev/null +++ b/hotkey_linux_test.go @@ -0,0 +1,46 @@ +// 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 + +//go:build linux +// +build linux + +package hotkey_test + +import ( + "context" + "fmt" + "testing" + "time" + + "golang.design/x/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) { + tt := time.Second * 5 + + ctx, cancel := context.WithTimeout(context.Background(), tt) + defer cancel() + + hk, err := hotkey.Register([]hotkey.Modifier{ + hotkey.ModCtrl, hotkey.Mod2, hotkey.Mod4}, hotkey.KeyA) + if err != nil { + t.Errorf("failed to register hotkey: %v", err) + return + } + + trigger := hk.Listen(ctx) + for { + select { + case <-ctx.Done(): + return + case <-trigger: + fmt.Println("triggered") + } + } +} diff --git a/hotkey_test.go b/hotkey_test.go new file mode 100644 index 0000000..5487740 --- /dev/null +++ b/hotkey_test.go @@ -0,0 +1,20 @@ +// 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 + +package hotkey_test + +import ( + "os" + "testing" + + "golang.design/x/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()) }) +} diff --git a/hotkey_windows.go b/hotkey_windows.go new file mode 100644 index 0000000..c539fb2 --- /dev/null +++ b/hotkey_windows.go @@ -0,0 +1,113 @@ +// 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 + +//go:build windows +// +build windows + +package hotkey + +import ( + "context" + + "golang.design/x/hotkey/internal/win" +) + +// 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 { + mod := uint8(0) + for _, m := range hk.mods { + mod = mod | uint8(m) + } + ok, err := win.RegisterHotKey(0, 1, uintptr(mod), uintptr(KeyS)) + if !ok { + return err + } + return nil +} + +// unregister deregisteres a system hotkey. +func (hk *Hotkey) unregister() { + // FIXME: unregister hotkey +} + +// msgHotkey represents hotkey message +const msgHotkey uint32 = 0x0312 + +// handle handles the hotkey event loop. +func (hk *Hotkey) handle(ctx context.Context) { + msg := win.MSG{} + // KNOWN ISSUE: This message loop need to press an additional + // hotkey to eventually return if ctx.Done() is ready. + for win.GetMessage(&msg, 0, 0, 0) { + switch msg.Message { + case msgHotkey: + select { + case <-ctx.Done(): + return + default: + hk.in <- Event{} + } + } + } +} + +// 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 uint8 + +// All kinds of Keys +const ( + 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 +) diff --git a/hotkey_windows_test.go b/hotkey_windows_test.go new file mode 100644 index 0000000..6a6ceaa --- /dev/null +++ b/hotkey_windows_test.go @@ -0,0 +1,46 @@ +// 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 + +//go:build windows +// +build windows + +package hotkey_test + +import ( + "context" + "fmt" + "testing" + "time" + + "golang.design/x/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, err := hotkey.Register([]hotkey.Modifier{ + hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyS) + if err != nil { + t.Errorf("failed to register hotkey: %v", err) + return + } + + trigger := hk.Listen(ctx) + for { + select { + case <-ctx.Done(): + return + case <-trigger: + fmt.Println("triggered") + } + } +} diff --git a/internal/cgo/handle.go b/internal/cgo/handle.go new file mode 100644 index 0000000..d7ff2ca --- /dev/null +++ b/internal/cgo/handle.go @@ -0,0 +1,103 @@ +// 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 + +// Package cgo is an implementation of golang.org/issue/37033. +package cgo + +import ( + "sync" + "sync/atomic" +) + +// Handle provides a safe representation of Go values to pass between +// C and Go back and forth. The zero value of a handle is not a valid +// handle, and thus safe to use as a sentinel in C APIs. +// +// The underlying type of Handle may change, but the value is guaranteed +// to fit in an integer type that is large enough to hold the bit pattern +// of any pointer. For instance, on the Go side: +// +// package main +// +// /* +// extern void MyGoPrint(unsigned long long handle); +// void myprint(unsigned long long handle); +// */ +// import "C" +// import "runtime/cgo" +// +// //export MyGoPrint +// func MyGoPrint(handle C.ulonglong) { +// h := cgo.Handle(handle) +// val := h.Value().(int) +// println(val) +// h.Delete() +// } +// +// func main() { +// val := 42 +// C.myprint(C.ulonglong(cgo.NewHandle(val))) +// // Output: 42 +// } +// +// and on the C side: +// +// // A Go function +// extern void MyGoPrint(unsigned long long handle); +// +// // A C function +// void myprint(unsigned long long handle) { +// MyGoPrint(handle); +// } +type Handle uintptr + +// NewHandle returns a handle for a given value. +// +// The handle is valid until the program calls Delete on it. The handle +// uses resources, and this package assumes that C code may hold on to +// the handle, so a program must explicitly call Delete when the handle +// is no longer needed. +// +// The intended use is to pass the returned handle to C code, which +// passes it back to Go, which calls Value. See an example in the +// comments of the Handle definition. +func NewHandle(v interface{}) Handle { + h := atomic.AddUintptr(&idx, 1) + if h == 0 { + panic("runtime/cgo: ran out of handle space") + } + + m.Store(h, v) + return Handle(h) +} + +// Value returns the associated Go value for a valid handle. +// +// The method panics if the handle is invalid already. +func (h Handle) Value() interface{} { + v, ok := m.Load(uintptr(h)) + if !ok { + panic("runtime/cgo: misuse of an invalid Handle") + } + return v +} + +// Delete invalidates a handle. This method must be called when C code no +// longer has a copy of the handle, and the program no longer needs the +// Go value that associated with the handle. +// +// The method panics if the handle is invalid already. +func (h Handle) Delete() { + _, ok := m.LoadAndDelete(uintptr(h)) + if !ok { + panic("runtime/cgo: misuse of an invalid Handle") + } +} + +var ( + m = &sync.Map{} // map[Handle]interface{} + idx uintptr // atomic +) diff --git a/internal/cgo/handle_test.go b/internal/cgo/handle_test.go new file mode 100644 index 0000000..fde3ef1 --- /dev/null +++ b/internal/cgo/handle_test.go @@ -0,0 +1,105 @@ +// 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 + +package cgo + +import ( + "reflect" + "testing" +) + +func TestHandle(t *testing.T) { + v := 42 + + tests := []struct { + v1 interface{} + v2 interface{} + }{ + {v1: v, v2: v}, + {v1: &v, v2: &v}, + {v1: nil, v2: nil}, + } + + for _, tt := range tests { + h1 := NewHandle(tt.v1) + h2 := NewHandle(tt.v2) + + if uintptr(h1) == 0 || uintptr(h2) == 0 { + t.Fatalf("NewHandle returns zero") + } + + if uintptr(h1) == uintptr(h2) { + t.Fatalf("Duplicated Go values could have different handles, but got equal") + } + + h1v := h1.Value() + h2v := h2.Value() + if !reflect.DeepEqual(h1v, h2v) || !reflect.DeepEqual(h1v, tt.v1) { + t.Fatalf("Value of a Handle got wrong, was: %+v, got: %+v %+v", tt.v1, h1v, h2v) + } + + h1.Delete() + h2.Delete() + } + + siz := 0 + m.Range(func(k, v interface{}) bool { + siz++ + return true + }) + if siz != 0 { + t.Fatalf("handles are not deleted, want: %d, got %d", 0, siz) + } +} + +func TestInvalidHandle(t *testing.T) { + t.Run("zero", func(t *testing.T) { + h := Handle(0) + + defer func() { + if r := recover(); r != nil { + return + } + t.Fatalf("Delete of zero handle did not trigger a panic") + }() + + h.Delete() + }) + + t.Run("invalid", func(t *testing.T) { + h := NewHandle(42) + + defer func() { + if r := recover(); r != nil { + h.Delete() + return + } + t.Fatalf("Invalid handle did not trigger a panic") + }() + + Handle(h + 1).Delete() + }) +} + +func BenchmarkHandle(b *testing.B) { + b.Run("non-concurrent", func(b *testing.B) { + for i := 0; i < b.N; i++ { + h := NewHandle(i) + _ = h.Value() + h.Delete() + } + }) + b.Run("concurrent", func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + var v int + for pb.Next() { + h := NewHandle(v) + _ = h.Value() + h.Delete() + } + }) + }) +} diff --git a/internal/win/hotkey.go b/internal/win/hotkey.go new file mode 100644 index 0000000..14532ba --- /dev/null +++ b/internal/win/hotkey.go @@ -0,0 +1,89 @@ +// 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 + +//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") + sendMessage = user32.NewProc("SendMessageW") +) + +// RegisterHotKey defines a system-wide hot key. +// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey +func RegisterHotKey(hwnd uintptr, id int, mod uintptr, k uintptr) (bool, error) { + ret, _, err := registerHotkey.Call( + hwnd, uintptr(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 uintptr, id int) (bool, error) { + ret, _, err := unregisterHotkey.Call(hwnd, uintptr(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 +}