mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
feat: implement Linux global hotkey support using X11 and XDG portal backends with integrated media controls.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user