feat: implement Linux global hotkey support using X11 and XDG portal backends with integrated media controls.

This commit is contained in:
jahruz67
2026-05-08 16:43:15 -07:00
parent f71d6ccd1d
commit 53637c6083
15 changed files with 845 additions and 243 deletions
+3 -1
View File
@@ -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
+97 -7
View File
@@ -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()
}
+29 -6
View File
@@ -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)
}