mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
feat: implement Linux global hotkey support using X11 and XDG portal backends with integrated media controls.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build !windows && !cgo
|
||||
//go:build !windows && !cgo && !linux
|
||||
|
||||
package hotkey
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user