diff --git a/go.mod b/go.mod index c4a92f2..184434f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/linux/media.go b/internal/linux/media.go index 4287c38..282028b 100644 --- a/internal/linux/media.go +++ b/internal/linux/media.go @@ -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 diff --git a/internal/linux/overlay.go b/internal/linux/overlay.go index 23754fa..92c3e6e 100644 --- a/internal/linux/overlay.go +++ b/internal/linux/overlay.go @@ -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() +} diff --git a/internal/linux/startup.go b/internal/linux/startup.go index 8ae03e7..a8e7cf5 100644 --- a/internal/linux/startup.go +++ b/internal/linux/startup.go @@ -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) } diff --git a/internal/xhotkey/hotkey_linux.c b/internal/xhotkey/hotkey_linux.c index a2cc7c2..319d10b 100644 --- a/internal/xhotkey/hotkey_linux.c +++ b/internal/xhotkey/hotkey_linux.c @@ -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; } \ No newline at end of file diff --git a/internal/xhotkey/hotkey_linux.go b/internal/xhotkey/hotkey_linux.go deleted file mode 100644 index c98d351..0000000 --- a/internal/xhotkey/hotkey_linux.go +++ /dev/null @@ -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 - -//go:build linux - -package hotkey - -/* -#cgo LDFLAGS: -lX11 - -#include - -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 -) diff --git a/internal/xhotkey/hotkey_linux_dispatch_cgo.go b/internal/xhotkey/hotkey_linux_dispatch_cgo.go new file mode 100644 index 0000000..a9cb8ff --- /dev/null +++ b/internal/xhotkey/hotkey_linux_dispatch_cgo.go @@ -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") + } +} diff --git a/internal/xhotkey/hotkey_linux_dispatch_nocgo.go b/internal/xhotkey/hotkey_linux_dispatch_nocgo.go new file mode 100644 index 0000000..91161a2 --- /dev/null +++ b/internal/xhotkey/hotkey_linux_dispatch_nocgo.go @@ -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() +} diff --git a/internal/xhotkey/hotkey_linux_platform.go b/internal/xhotkey/hotkey_linux_platform.go new file mode 100644 index 0000000..0dcc980 --- /dev/null +++ b/internal/xhotkey/hotkey_linux_platform.go @@ -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 +} diff --git a/internal/xhotkey/hotkey_linux_portal.go b/internal/xhotkey/hotkey_linux_portal.go new file mode 100644 index 0000000..7571dde --- /dev/null +++ b/internal/xhotkey/hotkey_linux_portal.go @@ -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 +} diff --git a/internal/xhotkey/hotkey_linux_types.go b/internal/xhotkey/hotkey_linux_types.go new file mode 100644 index 0000000..a9dcabc --- /dev/null +++ b/internal/xhotkey/hotkey_linux_types.go @@ -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 +) diff --git a/internal/xhotkey/hotkey_linux_x11.go b/internal/xhotkey/hotkey_linux_x11.go new file mode 100644 index 0000000..0e49985 --- /dev/null +++ b/internal/xhotkey/hotkey_linux_x11.go @@ -0,0 +1,80 @@ +//go:build linux && cgo + +package hotkey + +/* +#cgo LDFLAGS: -lX11 + +#include + +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{} +} diff --git a/internal/xhotkey/hotkey_nocgo.go b/internal/xhotkey/hotkey_nocgo.go index ece8d80..7e050a0 100644 --- a/internal/xhotkey/hotkey_nocgo.go +++ b/internal/xhotkey/hotkey_nocgo.go @@ -4,7 +4,7 @@ // // Written by Changkun Ou -//go:build !windows && !cgo +//go:build !windows && !cgo && !linux package hotkey diff --git a/internal/xhotkey/hotkey_nocgo_test.go b/internal/xhotkey/hotkey_nocgo_test.go index dc1f26a..8751058 100644 --- a/internal/xhotkey/hotkey_nocgo_test.go +++ b/internal/xhotkey/hotkey_nocgo_test.go @@ -4,7 +4,7 @@ // // Written by Changkun Ou -//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 { diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index 121ea31..3f23940 100755 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -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