feat: implement Linux global hotkey support using X11 and XDG portal backends with integrated media controls.

This commit is contained in:
jahruz67
2026-05-08 16:43:15 -07:00
parent f71d6ccd1d
commit 53637c6083
15 changed files with 845 additions and 243 deletions
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/gen2brain/malgo v0.11.24
github.com/getlantern/systray v1.2.2
github.com/go-vgo/robotgo v0.110.8
github.com/godbus/dbus/v5 v5.1.0
github.com/wailsapp/wails/v2 v2.11.0
golang.design/x/mainthread v0.3.0
golang.org/x/sys v0.33.0
@@ -24,7 +25,6 @@ require (
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+3 -1
View File
@@ -3,6 +3,8 @@ package linux
import (
"os/exec"
"strings"
"wis-free-v3/internal/logger"
)
@@ -13,7 +15,7 @@ func IsPlaying() bool {
if err != nil {
return false
}
return string(out) == "Playing\n"
return strings.EqualFold(strings.TrimSpace(string(out)), "playing")
}
// TogglePlayPause sends the play-pause command via playerctl
+97 -7
View File
@@ -1,15 +1,105 @@
//go:build linux
package linux
// linuxOverlay provides a simple dummy implementation for Linux.
// It relies on tray icon status updates for visual feedback instead of raw drawing.
type linuxOverlay struct{}
import (
"fmt"
"os/exec"
"strings"
"sync"
"wis-free-v3/internal/logger"
)
// linuxOverlay shows recording/transcription status via libnotify when available
// (notify-send). There is no full-screen overlay on Linux to avoid a GTK/Cairo
// dependency beyond what Wails already pulls in.
type linuxOverlay struct {
mu sync.Mutex
lastMsg string
}
func NewOverlay() *linuxOverlay {
return &linuxOverlay{}
}
func (o *linuxOverlay) Show(message string) {}
func (o *linuxOverlay) Hide() {}
func (o *linuxOverlay) SetVolume(level float64) {}
func (o *linuxOverlay) Close() {}
const overlayNotifyID = "wisfree-overlay"
var (
notifyOnce sync.Once
haveNotify bool
)
func detectNotifySend() {
_, err := exec.LookPath("notify-send")
haveNotify = err == nil
if !haveNotify {
logger.Info("notify-send not found; install libnotify-bin for recording status toasts on Linux")
}
}
func (o *linuxOverlay) Show(message string) {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
o.lastMsg = message
o.mu.Unlock()
cmd := exec.Command("notify-send",
"-a", "wis-free-v3",
"-r", overlayNotifyID,
"-u", "low",
"-t", "0",
message,
)
if err := cmd.Run(); err != nil {
logger.Error("notify-send failed: %v", err)
}
}
func (o *linuxOverlay) Hide() {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
o.lastMsg = ""
o.mu.Unlock()
// Replacing the same ID with a 1ms toast clears the bubble on many DEs (GNOME, KDE).
_ = exec.Command("notify-send", "-a", "wis-free-v3", "-r", overlayNotifyID, "-t", "1", " ").Run()
}
func (o *linuxOverlay) SetVolume(level float64) {
notifyOnce.Do(detectNotifySend)
if !haveNotify {
return
}
o.mu.Lock()
base := o.lastMsg
o.mu.Unlock()
if strings.TrimSpace(base) == "" {
return
}
pct := int(level*100 + 0.5)
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
body := fmt.Sprintf("%s — mic %d%%", base, pct)
cmd := exec.Command("notify-send",
"-a", "wis-free-v3",
"-r", overlayNotifyID,
"-u", "low",
"-t", "0",
body,
)
if err := cmd.Run(); err != nil {
logger.Error("notify-send failed: %v", err)
}
}
func (o *linuxOverlay) Close() {
o.Hide()
}
+29 -6
View File
@@ -7,17 +7,18 @@ import (
"path/filepath"
)
const (
appName = "wis-free-v3"
desktopFileContent = `[Desktop Entry]
const appName = "wis-free-v3"
// desktopFileTemplate is filled with execLine built from the absolute binary path.
// Paths with spaces must be quoted per the Desktop Entry spec.
const desktopFileTemplate = `[Desktop Entry]
Type=Application
Name=WIS Free V3
Exec="%s"
%s
Terminal=false
Categories=Utility;
X-GNOME-Autostart-enabled=true
`
)
func getAutostartPath() (string, error) {
home, err := os.UserHomeDir()
@@ -46,6 +47,28 @@ func getExecutablePath() (string, error) {
return filepath.Abs(exe)
}
// desktopExecField returns one line: Exec=/path or Exec="/path with spaces"
func desktopExecField(exePath string) string {
needsQuote := false
for _, r := range exePath {
if r == ' ' || r == '\t' || r == '"' || r == '\'' || r == '\\' {
needsQuote = true
break
}
}
if !needsQuote {
return "Exec=" + exePath
}
escaped := ""
for _, r := range exePath {
if r == '"' || r == '`' || r == '$' || r == '\\' {
escaped += `\`
}
escaped += string(r)
}
return `Exec="` + escaped + `"`
}
func AddToStartup() error {
autostartPath, err := getAutostartPath()
if err != nil {
@@ -57,7 +80,7 @@ func AddToStartup() error {
return fmt.Errorf("failed to get executable path: %w", err)
}
content := fmt.Sprintf(desktopFileContent, exePath)
content := fmt.Sprintf(desktopFileTemplate, desktopExecField(exePath))
if err := os.WriteFile(autostartPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write autostart file: %w", err)
}
+41 -5
View File
@@ -17,6 +17,31 @@ extern void hotkeyDown(uintptr_t hkhandle);
extern void hotkeyUp(uintptr_t hkhandle);
extern int checkCancel(uintptr_t hkhandle);
// Effective modifier masks for XGrabKey when NumLock / CapsLock are on.
// See: https://stackoverflow.com/questions/4037230/how-to-handle-global-hotkeys-with-x11-xlib
static unsigned int mod_masks[4];
static void init_mod_masks(unsigned int mod) {
mod_masks[0] = mod;
mod_masks[1] = mod | Mod2Mask;
mod_masks[2] = mod | LockMask;
mod_masks[3] = mod | Mod2Mask | LockMask;
}
static void grab_all(Display* d, int keycode, unsigned int mod, Window w) {
init_mod_masks(mod);
for (int i = 0; i < 4; i++) {
XGrabKey(d, keycode, mod_masks[i], w, False, GrabModeAsync, GrabModeAsync);
}
}
static void ungrab_all(Display* d, int keycode, unsigned int mod, Window w) {
init_mod_masks(mod);
for (int i = 0; i < 4; i++) {
XUngrabKey(d, keycode, mod_masks[i], w);
}
}
int displayTest() {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
@@ -27,6 +52,7 @@ int displayTest() {
if (d == NULL) {
return -1;
}
XCloseDisplay(d);
return 0;
}
@@ -47,8 +73,14 @@ int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
XkbSetDetectableAutoRepeat(d, True, &supported);
int keycode = XKeysymToKeycode(d, key);
XGrabKey(d, keycode, mod, DefaultRootWindow(d), False, GrabModeAsync, GrabModeAsync);
XSelectInput(d, DefaultRootWindow(d), KeyPressMask | KeyReleaseMask);
if (keycode == 0) {
XCloseDisplay(d);
return -1;
}
Window root = DefaultRootWindow(d);
grab_all(d, keycode, mod, root);
XSelectInput(d, root, KeyPressMask | KeyReleaseMask);
XEvent ev;
while(1) {
@@ -59,10 +91,14 @@ int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
XNextEvent(d, &ev);
switch(ev.type) {
case KeyPress:
hotkeyDown(hkhandle);
if (ev.xkey.keycode == (unsigned)keycode) {
hotkeyDown(hkhandle);
}
continue;
case KeyRelease:
hotkeyUp(hkhandle);
if (ev.xkey.keycode == (unsigned)keycode) {
hotkeyUp(hkhandle);
}
continue;
}
} else {
@@ -70,7 +106,7 @@ int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
}
}
XUngrabKey(d, keycode, mod, DefaultRootWindow(d));
ungrab_all(d, keycode, mod, root);
XCloseDisplay(d);
return 0;
}
-218
View File
@@ -1,218 +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
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)
}
}
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 {
// Just call it once! The C code handles its own loop until checkCancel returns 1.
_ = C.waitHotkey(C.uintptr_t(h), C.uint(mod), C.int(hk.key))
close(hk.canceled)
return
}
}
//export checkCancel
func checkCancel(h uintptr) C.int {
hk := cgo.Handle(h).Value().(*Hotkey)
select {
case <-hk.ctx.Done():
return 1
default:
return 0
}
}
//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{}
}
// 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)
)
// 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
)
@@ -0,0 +1,42 @@
//go:build linux && cgo
package hotkey
import (
"errors"
"log"
)
func (hk *Hotkey) register() error {
if usePortalBackend() {
if err := hk.registerPortal(); err == nil {
return nil
} else {
log.Printf("wis-free-v3: Wayland global-shortcuts portal unavailable (%v); trying X11", err)
}
}
return hk.registerX11()
}
func (hk *Hotkey) unregister() error {
hk.mu.Lock()
if !hk.registered {
hk.mu.Unlock()
return errors.New("hotkey is not registered.")
}
switch hk.backend {
case linuxHKPortal:
hk.registered = false
hk.mu.Unlock()
return hk.cleanupPortal()
case linuxHKX11:
hk.cancel()
hk.registered = false
hk.mu.Unlock()
<-hk.canceled
return nil
default:
hk.mu.Unlock()
return errors.New("hotkey: invalid backend state")
}
}
@@ -0,0 +1,24 @@
//go:build linux && !cgo
package hotkey
import "errors"
func (hk *Hotkey) register() error {
return hk.registerPortal()
}
func (hk *Hotkey) unregister() error {
hk.mu.Lock()
if !hk.registered {
hk.mu.Unlock()
return errors.New("hotkey is not registered.")
}
if hk.backend != linuxHKPortal {
hk.mu.Unlock()
return errors.New("hotkey: invalid backend state")
}
hk.registered = false
hk.mu.Unlock()
return hk.cleanupPortal()
}
+33
View File
@@ -0,0 +1,33 @@
//go:build linux
package hotkey
import (
"context"
"sync"
"github.com/godbus/dbus/v5"
)
const (
linuxHKNone = iota
linuxHKX11
linuxHKPortal
)
type platformHotkey struct {
mu sync.Mutex
registered bool
backend int
// X11 (CGO)
ctx context.Context
cancel context.CancelFunc
canceled chan struct{}
// Wayland / XDG portal global shortcuts
portalStop chan struct{}
portalDone chan struct{}
portalConn *dbus.Conn
sessionPath dbus.ObjectPath
}
+389
View File
@@ -0,0 +1,389 @@
//go:build linux
package hotkey
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/godbus/dbus/v5"
)
const (
portalBusName = "org.freedesktop.portal.Desktop"
portalObjectPath = "/org/freedesktop/portal/desktop"
ifaceGlobalShortcuts = "org.freedesktop.portal.GlobalShortcuts"
ifaceRequest = "org.freedesktop.portal.Request"
ifaceSession = "org.freedesktop.portal.Session"
wisfreeGlobalShortcutID = "com.wisfree.push-to-record"
envForcePortal = "WISFREE_USE_PORTAL_HOTKEY"
envForceX11 = "WISFREE_USE_X11_HOTKEY"
)
func usePortalBackend() bool {
if os.Getenv(envForceX11) == "1" {
return false
}
if os.Getenv(envForcePortal) == "1" {
return true
}
if os.Getenv("WAYLAND_DISPLAY") != "" {
return true
}
return strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland")
}
func randomPortalToken() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "tok" + fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func triggerForPortalSpec(mods []Modifier, key Key) string {
var ctrl, shift, alt, super bool
for _, m := range mods {
switch m {
case ModCtrl:
ctrl = true
case ModShift:
shift = true
case Mod1:
alt = true
case Mod4:
super = true
}
}
keyName := portalKeySpecName(key)
if keyName == "" {
return ""
}
var parts []string
if ctrl {
parts = append(parts, "Control")
}
if alt {
parts = append(parts, "Alt")
}
if shift {
parts = append(parts, "Shift")
}
if super {
parts = append(parts, "Super")
}
if len(parts) == 0 {
return keyName
}
return strings.Join(parts, "+") + "+" + keyName
}
func portalKeySpecName(key Key) string {
if key >= KeyA && key <= KeyZ {
return string(rune(key))
}
if key >= Key0 && key <= Key9 {
return string(rune(key))
}
switch key {
case KeySpace:
return "Space"
case KeyReturn:
return "Return"
case KeyEscape:
return "Escape"
case KeyDelete:
return "Delete"
case KeyTab:
return "Tab"
case KeyLeft:
return "Left"
case KeyRight:
return "Right"
case KeyUp:
return "Up"
case KeyDown:
return "Down"
case KeyF1:
return "F1"
case KeyF2:
return "F2"
case KeyF3:
return "F3"
case KeyF4:
return "F4"
case KeyF5:
return "F5"
case KeyF6:
return "F6"
case KeyF7:
return "F7"
case KeyF8:
return "F8"
case KeyF9:
return "F9"
case KeyF10:
return "F10"
case KeyF11:
return "F11"
case KeyF12:
return "F12"
default:
return ""
}
}
func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[string]dbus.Variant, error) {
ch := make(chan *dbus.Signal, 8)
conn.Signal(ch)
rule := fmt.Sprintf(
"type='signal',path='%s',interface='%s',member='Response'",
string(reqPath), ifaceRequest,
)
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
return 0, nil, err
}
defer func() {
_ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, rule).Store()
}()
timeout := time.NewTimer(45 * time.Second)
defer timeout.Stop()
for {
select {
case sig := <-ch:
if sig == nil || sig.Path != reqPath {
continue
}
if sig.Name != "Response" {
continue
}
if len(sig.Body) < 2 {
continue
}
code, ok := sig.Body[0].(uint32)
if !ok {
continue
}
results, _ := sig.Body[1].(map[string]dbus.Variant)
return code, results, nil
case <-timeout.C:
return 0, nil, fmt.Errorf("portal request timed out")
}
}
}
func variantToObjectPath(v dbus.Variant) (dbus.ObjectPath, bool) {
switch x := v.Value().(type) {
case dbus.ObjectPath:
return x, true
case string:
return dbus.ObjectPath(x), true
default:
return "", false
}
}
// registerPortal binds a global shortcut via org.freedesktop.portal.GlobalShortcuts (Wayland / desktop-agnostic).
func (hk *Hotkey) registerPortal() error {
trigger := triggerForPortalSpec(hk.mods, hk.key)
if trigger == "" {
return fmt.Errorf("unsupported key for portal global shortcuts")
}
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf("dbus session: %w", err)
}
portal := conn.Object(portalBusName, portalObjectPath)
createOpts := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(randomPortalToken()),
"session_handle_token": dbus.MakeVariant(randomPortalToken()),
}
var createReqPath dbus.ObjectPath
if err := portal.Call(ifaceGlobalShortcuts+".CreateSession", 0, createOpts).Store(&createReqPath); err != nil {
_ = conn.Close()
return fmt.Errorf("CreateSession: %w", err)
}
code, results, err := portalWaitRequest(conn, createReqPath)
if err != nil {
_ = conn.Close()
return fmt.Errorf("CreateSession wait: %w", err)
}
if code != 0 {
_ = conn.Close()
return fmt.Errorf("CreateSession rejected (code %d)", code)
}
v, ok := results["session_handle"]
if !ok {
_ = conn.Close()
return errors.New("CreateSession: missing session_handle")
}
sessPath, okp := variantToObjectPath(v)
if !okp || sessPath == "" {
_ = conn.Close()
return errors.New("CreateSession: invalid session_handle")
}
shortcutTuple := []interface{}{
wisfreeGlobalShortcutID,
map[string]dbus.Variant{
"description": dbus.MakeVariant("Hold to dictate; release to transcribe (WIS Free)"),
"preferred_trigger": dbus.MakeVariant(trigger),
},
}
shortcutsArg := []interface{}{shortcutTuple}
bindOpts := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(randomPortalToken()),
}
var bindReqPath dbus.ObjectPath
if err := portal.Call(ifaceGlobalShortcuts+".BindShortcuts", 0, sessPath, shortcutsArg, "", bindOpts).Store(&bindReqPath); err != nil {
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
_ = conn.Close()
return fmt.Errorf("BindShortcuts: %w", err)
}
code, results, err = portalWaitRequest(conn, bindReqPath)
if err != nil {
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
_ = conn.Close()
return err
}
if code != 0 {
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
_ = conn.Close()
return fmt.Errorf("BindShortcuts rejected (code %d); install a desktop with GlobalShortcuts portal support (e.g. recent KDE Plasma) or set %s=1 to force X11 hotkeys under XWayland", code, envForceX11)
}
if sc, ok := results["shortcuts"]; ok {
if ar, ok := sc.Value().([][]interface{}); ok && len(ar) == 0 {
log.Printf("wis-free-v3 hotkey: BindShortcuts returned empty shortcut list (desktop may have declined the binding)")
}
}
hk.mu.Lock()
if hk.registered {
hk.mu.Unlock()
_ = conn.Object(portalBusName, sessPath).Call(ifaceSession+".Close", 0).Store()
_ = conn.Close()
return errors.New("hotkey already registered.")
}
hk.backend = linuxHKPortal
hk.registered = true
hk.portalConn = conn
hk.sessionPath = sessPath
hk.portalStop = make(chan struct{})
hk.portalDone = make(chan struct{})
hk.mu.Unlock()
go hk.portalSignalLoop()
return nil
}
func (hk *Hotkey) portalSignalLoop() {
defer close(hk.portalDone)
hk.mu.Lock()
conn := hk.portalConn
sess := hk.sessionPath
hk.mu.Unlock()
if conn == nil {
return
}
ch := make(chan *dbus.Signal, 32)
conn.Signal(ch)
rule := fmt.Sprintf(
"type='signal',interface='%s',path='%s'",
ifaceGlobalShortcuts, string(portalObjectPath),
)
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
log.Printf("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err)
return
}
defer func() { _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, rule).Store() }()
for {
select {
case <-hk.portalStop:
return
case sig, ok := <-ch:
if !ok || sig == nil {
return
}
if sig.Path != portalObjectPath {
continue
}
if len(sig.Body) < 2 {
continue
}
sessVar, ok := sig.Body[0].(dbus.ObjectPath)
if !ok {
if s, ok := sig.Body[0].(string); ok {
sessVar = dbus.ObjectPath(s)
} else {
continue
}
}
if sessVar != sess {
continue
}
id, ok := sig.Body[1].(string)
if !ok || id != wisfreeGlobalShortcutID {
continue
}
name := sig.Name
switch {
case name == "Activated" || strings.HasSuffix(name, ".Activated"):
select {
case hk.keydownIn <- Event{}:
default:
}
case name == "Deactivated" || strings.HasSuffix(name, ".Deactivated"):
select {
case hk.keyupIn <- Event{}:
default:
}
}
}
}
}
// cleanupPortal stops the portal listener and closes the session (unlock before call).
func (hk *Hotkey) cleanupPortal() error {
hk.mu.Lock()
stopCh := hk.portalStop
doneCh := hk.portalDone
conn := hk.portalConn
sess := hk.sessionPath
hk.portalStop = nil
hk.portalDone = nil
hk.portalConn = nil
hk.sessionPath = ""
hk.backend = linuxHKNone
hk.mu.Unlock()
if stopCh != nil {
close(stopCh)
}
if doneCh != nil {
<-doneCh
}
if conn != nil && sess != "" {
_ = conn.Object(portalBusName, sess).Call(ifaceSession+".Close", 0).Store()
_ = conn.Close()
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
//go:build linux
package hotkey
// Modifier represents a modifier (X11 bitmask; also used when mapping to portal shortcut spec).
type Modifier uint32
// 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)
)
// Key represents a key (X11 keysym values; matches xhotkey / ParseShortcut on Linux).
type Key uint16
// See /usr/include/X11/keysymdef.h
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 = 0xff09
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
)
+80
View File
@@ -0,0 +1,80 @@
//go:build linux && cgo
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"
"fmt"
"runtime"
"runtime/cgo"
)
func (hk *Hotkey) registerX11() error {
if C.displayTest() != 0 {
return fmt.Errorf("X11 display not available (is DISPLAY set?)")
}
hk.mu.Lock()
if hk.registered {
hk.mu.Unlock()
return errors.New("hotkey already registered.")
}
hk.backend = linuxHKX11
hk.registered = true
hk.ctx, hk.cancel = context.WithCancel(context.Background())
hk.canceled = make(chan struct{})
hk.mu.Unlock()
go hk.handleX11()
return nil
}
func (hk *Hotkey) handleX11() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var mod Modifier
for _, m := range hk.mods {
mod = mod | m
}
h := cgo.NewHandle(hk)
defer h.Delete()
for {
_ = C.waitHotkey(C.uintptr_t(h), C.uint(mod), C.int(hk.key))
close(hk.canceled)
return
}
}
//export checkCancel
func checkCancel(h uintptr) C.int {
hk := cgo.Handle(h).Value().(*Hotkey)
select {
case <-hk.ctx.Done():
return 1
default:
return 0
}
}
//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{}
}
+1 -1
View File
@@ -4,7 +4,7 @@
//
// Written by Changkun Ou <changkun.de>
//go:build !windows && !cgo
//go:build !windows && !cgo && !linux
package hotkey
+2 -4
View File
@@ -4,7 +4,7 @@
//
// Written by Changkun Ou <changkun.de>
//go:build (linux || darwin) && !cgo
//go:build darwin && !cgo
package hotkey_test
@@ -14,9 +14,7 @@ import (
"wis-free-v3/internal/xhotkey"
)
// 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)
// Without CGO on Darwin, registration is unsupported (panic). Linux without CGO uses the portal backend instead.
func TestHotkey(t *testing.T) {
defer func() {
if r := recover(); r != nil {
+11
View File
@@ -79,13 +79,24 @@ fi
if [ $MISSING_DEPS -eq 1 ]; then
echo ""
echo "It looks like you are missing some required libraries."
echo ""
echo "Wayland note: global hotkeys use the XDG GlobalShortcuts portal when WAYLAND_DISPLAY"
echo "or XDG_SESSION_TYPE=wayland is set (xdg-desktop-portal + a supporting compositor, e.g. KDE Plasma)."
echo "Override: WISFREE_USE_X11_HOTKEY=1 forces X11 grabs (needs XWayland);"
echo "WISFREE_USE_PORTAL_HOTKEY=1 forces the portal on X11 sessions for testing."
echo ""
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libx11-dev libx11-xcb-dev libxtst-dev libasound2-dev libayatana-appindicator3-dev libxkbcommon-x11-dev"
# Runtime niceties (optional): libnotify-bin — status toasts; playerctl — pause media while recording
DEBIAN_RUNTIME_OPT="libnotify-bin playerctl"
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk libx11 libxtst alsa-lib libayatana-appindicator libxkbcommon-x11"
ARCH_RUNTIME_OPT="libnotify playerctl"
echo "The full list of dependencies needed:"
echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS"
echo " [Ubuntu/Debian] optional: sudo apt install -y $DEBIAN_RUNTIME_OPT"
echo " [Arch Linux]: sudo pacman -S $ARCH_DEPS"
echo " [Arch Linux] optional: sudo pacman -S $ARCH_RUNTIME_OPT"
echo ""
if command_exists apt-get; then