Refactor Linux hotkey handling and improve installation process

- Removed the legacy HTTP daemon for handling hotkey presses on Linux, replacing it with a simpler command-based approach using `--action=toggle`.
- Updated the build script to support non-interactive installation for CI environments with the `--no-install` flag.
- Enhanced the ydotool setup process to prefer distribution-provided systemd units and improved error messages for missing dependencies.
- Changed the way active window titles are retrieved on Linux to avoid using robotgo, which can fail on Wayland.
- Updated README and frontend hints to clarify the use of GlobalShortcuts portal and fallback commands for Linux.
- Improved package building scripts to allow building only Debian or RPM packages based on command-line flags.
- Adjusted the text insertion logic to handle different versions of ydotool more robustly.
This commit is contained in:
John Doe
2026-07-10 14:58:25 -07:00
parent 26022d67fb
commit 5b6e39c5e8
11 changed files with 392 additions and 378 deletions
+31 -219
View File
@@ -1,57 +1,31 @@
//go:build linux
// ============================================================
// LINUX-ONLY FILE — Local HTTP daemon that receives hotkey
// press/release pings from the GNOME custom shortcut helper.
// This is the Linux equivalent of the Windows hotkey listener.
// Any changes here will NOT affect the Windows build.
// ============================================================
// Linux shortcut compatibility helpers.
//
// Older releases used a loopback HTTP server for the `--press` command. That
// made a normal desktop shortcut unreliable: a second key press inside the
// hold-detection window could be mistaken for key auto-repeat, and another
// local process could occupy the fixed TCP port. Linux already has a
// per-user Unix socket for single-instance IPC, so use that authenticated
// per-user path instead.
package main
import (
"context"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"wis-free-v3/internal/logger"
)
const linuxPressAddr = "127.0.0.1:9876"
// linuxPressState holds the daemon's state, protected by a mutex.
// All field access must be done while holding the mutex.
type linuxPressState struct {
mu sync.Mutex
releaseTimer *time.Timer
detectTimer *time.Timer
recording bool
holdMode bool
detectingHold bool
cycle uint64
}
var pressState linuxPressState
const (
// linuxPressHoldDetectWindow is the window in which a second ping indicates
// the shortcut is being held (push-to-talk mode).
linuxPressHoldDetectWindow = 1200 * time.Millisecond
// linuxPressReleaseGrace is how long after the last ping to wait before
// stopping recording in hold mode.
linuxPressReleaseGrace = 450 * time.Millisecond
"strings"
)
func init() {
// Keep `--press` working for existing custom shortcuts. A desktop shortcut
// invokes a command once, so its correct, predictable behaviour is toggle
// (start on the first press and stop on the next), not push-to-talk.
if hasPressFlag(os.Args[1:]) {
if err := sendLinuxPressPing(); err != nil {
os.Exit(1)
if tryNotifyRunningInstanceAction("toggle") {
os.Exit(0)
}
os.Exit(0)
// Do not launch a second GUI process from this legacy helper. The current
// settings UI emits --action=toggle, which can start the app if needed.
os.Exit(1)
}
}
@@ -64,184 +38,22 @@ func hasPressFlag(args []string) bool {
return false
}
func sendLinuxPressPing() error {
client := &http.Client{Timeout: 300 * time.Millisecond}
resp, err := client.Get("http://" + linuxPressAddr + "/press")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return os.ErrNotExist
}
return nil
// linuxShortcutCommand returns a shell-safe command for GNOME/KDE custom
// shortcuts. Quote the executable because user home directories can contain
// spaces or shell metacharacters.
func linuxShortcutCommand(executable string) string {
return linuxShellQuote(executable) + " --action=toggle"
}
// linuxPressServer holds the HTTP server reference for graceful shutdown.
var linuxPressServer *http.Server
func (a *App) startLinuxPressDaemon() {
mux := http.NewServeMux()
mux.HandleFunc("/press", func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press handler recovered from panic: %v", recovered)
http.Error(w, "press handler failed", http.StatusInternalServerError)
}
}()
if r.Method != http.MethodGet && r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
a.handleLinuxPressPing()
w.WriteHeader(http.StatusNoContent)
})
go func() {
server := &http.Server{
Addr: linuxPressAddr,
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
IdleTimeout: 5 * time.Second,
}
linuxPressServer = server
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Linux press daemon stopped: %v", err)
}
}()
func linuxShellQuote(value string) string {
if value == "" {
return "''"
}
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func (a *App) handleLinuxPressPing() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
// The old daemon API remains a no-op so the common lifecycle code and
// non-Linux stub stay simple. Shortcut delivery now uses the instance socket.
func (a *App) startLinuxPressDaemon() {}
if !pressState.recording {
// First ping: start recording with hold detection
pressState.cycle++
cycle := pressState.cycle
pressState.recording = true
pressState.holdMode = false
pressState.detectingHold = true
go a.startLinuxPressRecording(cycle)
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
}
pressState.detectTimer = time.AfterFunc(linuxPressHoldDetectWindow, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle == cycle {
pressState.detectingHold = false
}
})
return
}
// Already recording. If we're still in the hold-detect window or confirmed hold mode,
// this is a hold-mode keepalive ping (key auto-repeat).
if pressState.detectingHold || pressState.holdMode {
pressState.holdMode = true
pressState.detectingHold = false
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
a.resetLinuxPressReleaseTimerLocked()
return
}
// Already recording but not in hold mode: this is a toggle-off ping (second press).
a.stopLinuxPressRecordingLocked()
}
func (a *App) resetLinuxPressReleaseTimerLocked() {
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
}
cycle := pressState.cycle
pressState.releaseTimer = time.AfterFunc(linuxPressReleaseGrace, func() {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle == cycle && pressState.recording && pressState.holdMode {
a.stopLinuxPressRecordingLocked()
}
})
}
func (a *App) stopLinuxPressRecordingLocked() {
// Caller must hold pressState.mu
wasRecording := pressState.recording
pressState.cycle++
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
}
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
pressState.recording = false
pressState.holdMode = false
pressState.detectingHold = false
if wasRecording {
go a.stopLinuxPressRecording()
}
}
func (a *App) startLinuxPressRecording(cycle uint64) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press start recovered from panic: %v", recovered)
a.resetFailedLinuxPressCycle(cycle)
}
}()
a.StartRecording()
if atomic.LoadInt32(&a.recording) == 0 {
a.resetFailedLinuxPressCycle(cycle)
}
}
func (a *App) stopLinuxPressRecording() {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("Linux press stop recovered from panic: %v", recovered)
}
}()
a.StopRecording()
}
func (a *App) resetFailedLinuxPressCycle(cycle uint64) {
pressState.mu.Lock()
defer pressState.mu.Unlock()
if pressState.cycle != cycle {
return
}
pressState.cycle++
if pressState.releaseTimer != nil {
pressState.releaseTimer.Stop()
pressState.releaseTimer = nil
}
if pressState.detectTimer != nil {
pressState.detectTimer.Stop()
pressState.detectTimer = nil
}
pressState.recording = false
pressState.holdMode = false
pressState.detectingHold = false
}
func stopLinuxPressDaemon() {
if linuxPressServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := linuxPressServer.Shutdown(ctx); err != nil {
logger.Error("Error shutting down Linux press daemon: %v", err)
}
}
}
func stopLinuxPressDaemon() {}