This commit is contained in:
Your Name
2026-05-09 13:51:20 -07:00
parent 6d1fe33c60
commit f111695a45
25 changed files with 376 additions and 299 deletions
+3 -3
View File
@@ -154,11 +154,11 @@ func (l *Listener) eventLoop(hk *xhk.Hotkey) {
// If Wayland doesn't send Deactivated (Keyup), this acts as a Toggle fallback.
select {
case <-hk.Keyup():
// Out-of-order X11 auto-repeat. Ignore both.
// Out-of-order auto-repeat. Ignore both.
case <-time.After(20 * time.Millisecond):
// Genuine second press. Toggle off.
logger.Info("Shortcut activated again: stopping recording (Wayland toggle fallback)")
go l.stopCallback()
logger.Info("Shortcut activated again: toggling recording (Wayland toggle fallback)")
go l.startCallback()
isRecording = false
}
}
+1
View File
@@ -9,6 +9,7 @@ var ModMap = map[string]xhk.Modifier{
"control": xhk.ModCtrl,
"shift": xhk.ModShift,
"alt": xhk.Mod1,
"option": xhk.Mod1,
"win": xhk.Mod4,
"windows": xhk.Mod4,
"meta": xhk.Mod4,
+24 -4
View File
@@ -14,19 +14,24 @@ const appName = "wis-free-v3"
const baseDesktopFileTemplate = `[Desktop Entry]
Type=Application
Name=WIS Free V3
Comment=Voice Recording and Transcription
%s
%s
Icon=%s
StartupWMClass=%s
Terminal=false
Categories=Utility;
Keywords=voice;recorder;transcription;
`
const autostartDesktopFileTemplate = `[Desktop Entry]
Type=Application
Name=WIS Free V3
Comment=Voice Recording and Transcription
%s
Icon=wis-free-v3
StartupWMClass=wis-free-v3
%s
Icon=%s
StartupWMClass=%s
Terminal=false
Categories=Utility;
X-GNOME-Autostart-enabled=true
@@ -60,7 +65,7 @@ func EnsureDesktopFile(iconBytes []byte) error {
return fmt.Errorf("failed to write icon to pixmaps: %w", err)
}
content := fmt.Sprintf(baseDesktopFileTemplate, desktopExecField(exePath), appName, appName)
content := fmt.Sprintf(baseDesktopFileTemplate, desktopExecField(exePath), desktopTryExecField(exePath), iconPath, appName)
return os.WriteFile(desktopPath, []byte(content), 0644)
}
@@ -113,6 +118,11 @@ func desktopExecField(exePath string) string {
return `Exec="` + escaped + `"`
}
// desktopTryExecField returns one line: TryExec=/path (desktop entries do not accept quoting for TryExec)
func desktopTryExecField(exePath string) string {
return "TryExec=" + exePath
}
func AddToStartup() error {
autostartPath, err := getAutostartPath()
if err != nil {
@@ -124,7 +134,17 @@ func AddToStartup() error {
return fmt.Errorf("failed to get executable path: %w", err)
}
content := fmt.Sprintf(autostartDesktopFileTemplate, desktopExecField(exePath))
// Prefer a concrete icon file path if we already created one in EnsureDesktopFile.
// Fall back to the icon name (theme lookup) if it doesn't exist.
iconValue := appName
if home, err := os.UserHomeDir(); err == nil {
iconPath := filepath.Join(home, ".local", "share", "pixmaps", appName+".png")
if _, statErr := os.Stat(iconPath); statErr == nil {
iconValue = iconPath
}
}
content := fmt.Sprintf(autostartDesktopFileTemplate, desktopExecField(exePath), desktopTryExecField(exePath), iconValue, appName)
if err := os.WriteFile(autostartPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write autostart file: %w", err)
}
-15
View File
@@ -46,21 +46,6 @@ var statusMenuItem *systray.MenuItem
var triggerCountItem *systray.MenuItem
var triggerCount int
// Start initializes and runs the system tray.
// This function blocks until the tray is terminated.
func Start(app App) {
// Pin this goroutine to one OS thread for the whole lifetime of systray.Run.
// getlantern/systray creates the notify icon and runs the Win32 message pump on
// whichever thread calls Run; if the goroutine migrates between threads, callbacks
// and the tray context menu break until restart (often seen after sleep/resume).
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(
func() { onReady(app) },
onExit,
)
}
func appDisplayName(app App) string {
v := app.Version()
if v != "" && v != "dev" {
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package tray
import "github.com/getlantern/systray"
// On Linux, Wails already runs the GTK main loop on the main thread.
// Calling systray.Run() would start a second gtk_main() and may abort.
// Instead, we only register the tray and let the existing GTK loop drive it.
func Start(app App) {
systray.Register(
func() { onReady(app) },
onExit,
)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !linux
package tray
import (
"runtime"
"github.com/getlantern/systray"
)
// Start initializes and runs the system tray.
// This function blocks until the tray is terminated.
func Start(app App) {
// Pin this goroutine to one OS thread for the whole lifetime of systray.Run.
// getlantern/systray creates the notify icon and runs the Win32 message pump on
// whichever thread calls Run; if the goroutine migrates between threads, callbacks
// and the tray context menu break until restart (often seen after sleep/resume).
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(
func() { onReady(app) },
onExit,
)
}
-111
View File
@@ -1,111 +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
#include <stdint.h>
#include <stdio.h>
#include <X11/Xlib.h>
#include <X11/Xutil.h>
#include <X11/XKBlib.h>
#include <unistd.h>
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++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
break;
}
if (d == NULL) {
return -1;
}
XCloseDisplay(d);
return 0;
}
// waitHotkey blocks until the hotkey is triggered.
int waitHotkey(uintptr_t hkhandle, unsigned int mod, int key) {
Display* d = NULL;
for (int i = 0; i < 42; i++) {
d = XOpenDisplay(0);
if (d == NULL) continue;
break;
}
if (d == NULL) {
return -1;
}
// Optional: Ask X server to only send one release at the physical end of auto-repeat.
Bool supported;
XkbSetDetectableAutoRepeat(d, True, &supported);
int keycode = XKeysymToKeycode(d, key);
if (keycode == 0) {
XCloseDisplay(d);
return -1;
}
Window root = DefaultRootWindow(d);
grab_all(d, keycode, mod, root);
XEvent ev;
while(1) {
if (checkCancel(hkhandle) == 1) {
break;
}
if (XPending(d) > 0) {
XNextEvent(d, &ev);
switch(ev.type) {
case KeyPress:
if (ev.xkey.keycode == (unsigned)keycode) {
hotkeyDown(hkhandle);
}
continue;
case KeyRelease:
if (ev.xkey.keycode == (unsigned)keycode) {
hotkeyUp(hkhandle);
}
continue;
}
} else {
usleep(10000); // Poll every 10ms for snappy responsiveness without high CPU
}
}
ungrab_all(d, keycode, mod, root);
XCloseDisplay(d);
return 0;
}
@@ -1,4 +1,4 @@
//go:build linux && !cgo
//go:build linux
package hotkey
@@ -14,10 +14,6 @@ func (hk *Hotkey) unregister() error {
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()
@@ -1,42 +0,0 @@
//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")
}
}
@@ -3,7 +3,6 @@
package hotkey
import (
"context"
"sync"
"github.com/godbus/dbus/v5"
@@ -11,7 +10,6 @@ import (
const (
linuxHKNone = iota
linuxHKX11
linuxHKPortal
)
@@ -20,11 +18,6 @@ type platformHotkey struct {
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{}
+9 -16
View File
@@ -23,20 +23,13 @@ const (
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" {
if os.Getenv(envForcePortal) == "0" {
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")
return true
}
func randomPortalToken() string {
@@ -162,7 +155,7 @@ func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[st
if sig == nil || sig.Path != reqPath {
continue
}
if sig.Name != "Response" {
if !strings.HasSuffix(sig.Name, ".Response") {
continue
}
if len(sig.Body) < 2 {
@@ -208,6 +201,9 @@ func (hk *Hotkey) registerPortal() error {
createOpts := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(randomPortalToken()),
"session_handle_token": dbus.MakeVariant(randomPortalToken()),
// Required by xdg-desktop-portal on some desktops (e.g. Fedora/GNOME).
// This should match the app's .desktop file id when possible.
"app_id": dbus.MakeVariant("wis-free-v3"),
}
var createReqPath dbus.ObjectPath
@@ -270,7 +266,7 @@ func (hk *Hotkey) registerPortal() error {
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)
return fmt.Errorf("BindShortcuts rejected (code %d); install a desktop with GlobalShortcuts portal support (e.g. recent KDE Plasma or GNOME)", code)
}
if sc, ok := results["shortcuts"]; ok {
val := sc.Value()
@@ -323,8 +319,8 @@ func (hk *Hotkey) portalSignalLoop() {
ch := make(chan *dbus.Signal, 32)
conn.Signal(ch)
rule := fmt.Sprintf(
"type='signal',interface='%s',path='%s'",
ifaceGlobalShortcuts, string(portalObjectPath),
"type='signal',interface='%s'",
ifaceGlobalShortcuts,
)
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule).Store(); err != nil {
log.Printf("wis-free-v3 hotkey: AddMatch GlobalShortcuts: %v", err)
@@ -340,9 +336,6 @@ func (hk *Hotkey) portalSignalLoop() {
if !ok || sig == nil {
return
}
if sig.Path != portalObjectPath {
continue
}
if len(sig.Body) < 2 {
continue
}
-80
View File
@@ -1,80 +0,0 @@
//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{}
}