mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
14.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
Reference in New Issue
Block a user