- Web-based key editor (Alpine.js SPA) - Actions: shell commands, scripts, built-in media/volume/brightness, page switching - Keyboard shortcut action (wtype/xdotool) - On-device display with periodic command output - Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11) - Screensaver with idle detection - Config hot-reload - Multi-device support
452 lines
11 KiB
Go
452 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"streamdeck-lets-go/internal/config"
|
|
)
|
|
|
|
type WindowDetector interface {
|
|
Start(ctx context.Context) (<-chan Window, error)
|
|
Close()
|
|
}
|
|
|
|
type Window struct {
|
|
WMClass string
|
|
Title string
|
|
}
|
|
|
|
func NewWindowDetector() WindowDetector {
|
|
desktop := os.Getenv("XDG_CURRENT_DESKTOP")
|
|
wayland := os.Getenv("WAYLAND_DISPLAY")
|
|
|
|
slog.Debug("detecting desktop environment", "XDG_CURRENT_DESKTOP", desktop, "WAYLAND_DISPLAY", wayland)
|
|
|
|
switch {
|
|
case desktop == "Hyprland":
|
|
slog.Info("auto-switch: using Hyprland detector")
|
|
d := &hyprlandDetector{}
|
|
d.PollInterval = 200 * time.Millisecond
|
|
return d
|
|
|
|
case desktop == "sway":
|
|
slog.Info("auto-switch: using Sway detector")
|
|
d := &swayDetector{}
|
|
d.PollInterval = 500 * time.Millisecond
|
|
return d
|
|
|
|
case desktop == "niri":
|
|
slog.Info("auto-switch: using Niri detector")
|
|
d := &niriDetector{}
|
|
d.PollInterval = 200 * time.Millisecond
|
|
return d
|
|
|
|
case desktop == "GNOME" || strings.Contains(desktop, "GNOME"):
|
|
slog.Info("auto-switch: GNOME detector (stub)")
|
|
d := &gnomeDetector{}
|
|
d.PollInterval = 500 * time.Millisecond
|
|
return d
|
|
|
|
case strings.Contains(desktop, "KDE") || strings.Contains(desktop, "plasma"):
|
|
slog.Info("auto-switch: KDE detector (stub)")
|
|
d := &kdeDetector{}
|
|
d.PollInterval = 500 * time.Millisecond
|
|
return d
|
|
|
|
case wayland != "":
|
|
slog.Warn("auto-switch: unknown Wayland compositor, using portal fallback")
|
|
d := &portalDetector{}
|
|
d.PollInterval = 500 * time.Millisecond
|
|
return d
|
|
|
|
default:
|
|
slog.Info("auto-switch: using X11 detector")
|
|
d := &x11Detector{}
|
|
d.PollInterval = 200 * time.Millisecond
|
|
return d
|
|
}
|
|
}
|
|
|
|
type basePoll struct {
|
|
PollInterval time.Duration
|
|
pollFn func(context.Context) Window
|
|
cancel context.CancelFunc
|
|
lastRaw string
|
|
}
|
|
|
|
func (b *basePoll) start(ctx context.Context, ch chan<- Window) {
|
|
ticker := time.NewTicker(b.PollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
win := b.pollFn(ctx)
|
|
raw := win.WMClass + "|" + win.Title
|
|
if raw != b.lastRaw {
|
|
b.lastRaw = raw
|
|
select {
|
|
case ch <- win:
|
|
default:
|
|
}
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *basePoll) stop() {
|
|
if b.cancel != nil {
|
|
b.cancel()
|
|
}
|
|
}
|
|
|
|
func runCmd(ctx context.Context, cmd string, args ...string) (string, error) {
|
|
c := exec.CommandContext(ctx, cmd, args...)
|
|
out, err := c.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
func runCmdJSON(ctx context.Context, cmd string, args ...string) ([]byte, error) {
|
|
c := exec.CommandContext(ctx, cmd, args...)
|
|
return c.Output()
|
|
}
|
|
|
|
type hyprlandDetector struct {
|
|
basePoll
|
|
}
|
|
|
|
func (d *hyprlandDetector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (d *hyprlandDetector) Close() { d.basePoll.stop() }
|
|
|
|
type hyprlandWindow struct {
|
|
Class string `json:"class"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (d *hyprlandDetector) getWindow(ctx context.Context) Window {
|
|
data, err := runCmdJSON(ctx, "hyprctl", "activewindow", "-j")
|
|
if err != nil {
|
|
return Window{}
|
|
}
|
|
|
|
var win hyprlandWindow
|
|
if err := json.Unmarshal(data, &win); err != nil {
|
|
return Window{}
|
|
}
|
|
return Window{WMClass: win.Class, Title: win.Title}
|
|
}
|
|
|
|
type swayDetector struct {
|
|
basePoll
|
|
}
|
|
|
|
func (d *swayDetector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (d *swayDetector) Close() { d.basePoll.stop() }
|
|
|
|
type swayNode struct {
|
|
Type string `json:"type"`
|
|
Focused bool `json:"focused"`
|
|
AppID *string `json:"app_id"`
|
|
Name string `json:"name"`
|
|
Nodes []swayNode `json:"nodes"`
|
|
Floating []swayNode `json:"floating_nodes"`
|
|
Props *swayWindowProps `json:"window_properties"`
|
|
}
|
|
|
|
type swayWindowProps struct {
|
|
Class string `json:"class"`
|
|
}
|
|
|
|
func (d *swayDetector) getWindow(ctx context.Context) Window {
|
|
data, err := runCmdJSON(ctx, "swaymsg", "-t", "get_tree")
|
|
if err != nil {
|
|
return Window{}
|
|
}
|
|
|
|
var root swayNode
|
|
if err := json.Unmarshal(data, &root); err != nil {
|
|
return Window{}
|
|
}
|
|
|
|
node := findFocusedSwayNode(&root)
|
|
if node == nil {
|
|
return Window{}
|
|
}
|
|
|
|
wmClass := ""
|
|
if node.AppID != nil {
|
|
wmClass = *node.AppID
|
|
} else if node.Props != nil {
|
|
wmClass = node.Props.Class
|
|
}
|
|
|
|
return Window{WMClass: wmClass, Title: node.Name}
|
|
}
|
|
|
|
func findFocusedSwayNode(node *swayNode) *swayNode {
|
|
if node.Type == "con" && node.Focused {
|
|
return node
|
|
}
|
|
for i := range node.Nodes {
|
|
if found := findFocusedSwayNode(&node.Nodes[i]); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
for i := range node.Floating {
|
|
if found := findFocusedSwayNode(&node.Floating[i]); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type niriDetector struct {
|
|
basePoll
|
|
}
|
|
|
|
func (d *niriDetector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (d *niriDetector) Close() { d.basePoll.stop() }
|
|
|
|
type niriWindow struct {
|
|
AppID *string `json:"app_id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
func (d *niriDetector) getWindow(ctx context.Context) Window {
|
|
data, err := runCmdJSON(ctx, "niri", "msg", "--json", "focused-window")
|
|
if err != nil {
|
|
return Window{}
|
|
}
|
|
|
|
var win niriWindow
|
|
if err := json.Unmarshal(data, &win); err != nil {
|
|
return Window{}
|
|
}
|
|
if win.AppID == nil {
|
|
return Window{}
|
|
}
|
|
|
|
return Window{WMClass: *win.AppID, Title: win.Title}
|
|
}
|
|
|
|
type gnomeDetector struct{ basePoll }
|
|
func (d *gnomeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
func (d *gnomeDetector) Close() { d.basePoll.stop() }
|
|
|
|
func (d *gnomeDetector) getWindow(ctx context.Context) Window {
|
|
rawClass, err := runCmd(ctx, "gdbus", "call", "--session",
|
|
"--dest", "org.gnome.Shell",
|
|
"--object-path", "/org/gnome/Shell",
|
|
"--method", "org.gnome.Shell.Eval",
|
|
"global.display.focus_window?.get_wm_class() ?? ''")
|
|
if err != nil {
|
|
return Window{}
|
|
}
|
|
rawTitle, err := runCmd(ctx, "gdbus", "call", "--session",
|
|
"--dest", "org.gnome.Shell",
|
|
"--object-path", "/org/gnome/Shell",
|
|
"--method", "org.gnome.Shell.Eval",
|
|
"global.display.focus_window?.title ?? ''")
|
|
if err != nil {
|
|
_ = rawClass
|
|
return Window{}
|
|
}
|
|
|
|
wmClass := parseGnomeEval(rawClass)
|
|
title := parseGnomeEval(rawTitle)
|
|
return Window{WMClass: wmClass, Title: title}
|
|
}
|
|
|
|
func parseGnomeEval(out string) string {
|
|
out = strings.TrimSpace(out)
|
|
out = strings.TrimPrefix(out, "(true, ")
|
|
out = strings.TrimSuffix(out, ")")
|
|
out = strings.Trim(out, `"'`)
|
|
return out
|
|
}
|
|
|
|
type kdeDetector struct{ basePoll }
|
|
func (d *kdeDetector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
func (d *kdeDetector) Close() { d.basePoll.stop() }
|
|
|
|
func (d *kdeDetector) getWindow(ctx context.Context) Window {
|
|
wmClass, _ := runCmd(ctx, "kdotool", "getactivewindow", "getclassname")
|
|
title, _ := runCmd(ctx, "kdotool", "getactivewindow", "getwindowname")
|
|
return Window{WMClass: wmClass, Title: title}
|
|
}
|
|
|
|
type x11Detector struct{ basePoll }
|
|
func (d *x11Detector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
func (d *x11Detector) Close() { d.basePoll.stop() }
|
|
|
|
func (d *x11Detector) getWindow(ctx context.Context) Window {
|
|
wmClass, _ := runCmd(ctx, "xdotool", "getactivewindow", "getclassname")
|
|
title, _ := runCmd(ctx, "xdotool", "getactivewindow", "getwindowname")
|
|
return Window{WMClass: wmClass, Title: title}
|
|
}
|
|
|
|
type portalDetector struct{ basePoll }
|
|
func (d *portalDetector) Start(ctx context.Context) (<-chan Window, error) {
|
|
d.pollFn = d.getWindow
|
|
ctx, d.cancel = context.WithCancel(ctx)
|
|
ch := make(chan Window, 4)
|
|
go d.basePoll.start(ctx, ch)
|
|
return ch, nil
|
|
}
|
|
func (d *portalDetector) Close() { d.basePoll.stop() }
|
|
|
|
func (d *portalDetector) getWindow(ctx context.Context) Window {
|
|
return Window{}
|
|
}
|
|
|
|
type AutoSwitchManager struct {
|
|
mu sync.RWMutex
|
|
rules []compiledRule
|
|
lastManualPage string
|
|
autoPage string
|
|
autoStay bool
|
|
}
|
|
|
|
type compiledRule struct {
|
|
wmClass *regexp.Regexp
|
|
title *regexp.Regexp
|
|
rule config.SwitchRule
|
|
}
|
|
|
|
func NewAutoSwitchManager(rules []config.SwitchRule, defaultPage string) *AutoSwitchManager {
|
|
m := &AutoSwitchManager{
|
|
lastManualPage: defaultPage,
|
|
}
|
|
m.Reload(rules)
|
|
return m
|
|
}
|
|
|
|
func (m *AutoSwitchManager) Reload(rules []config.SwitchRule) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.rules = make([]compiledRule, 0, len(rules))
|
|
for _, r := range rules {
|
|
cr := compiledRule{rule: r}
|
|
if r.WMClass != "" {
|
|
re, err := regexp.Compile(r.WMClass)
|
|
if err != nil {
|
|
slog.Warn("auto-switch: invalid wm_class regex", "pattern", r.WMClass, "error", err)
|
|
continue
|
|
}
|
|
cr.wmClass = re
|
|
}
|
|
if r.Title != "" {
|
|
re, err := regexp.Compile(r.Title)
|
|
if err != nil {
|
|
slog.Warn("auto-switch: invalid title regex", "pattern", r.Title, "error", err)
|
|
continue
|
|
}
|
|
cr.title = re
|
|
}
|
|
m.rules = append(m.rules, cr)
|
|
}
|
|
|
|
slog.Info("auto-switch: rules loaded", "count", len(m.rules))
|
|
}
|
|
|
|
func (m *AutoSwitchManager) NotifyManualPage(name string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.lastManualPage = name
|
|
m.autoPage = ""
|
|
slog.Debug("auto-switch: manual page set", "page", name)
|
|
}
|
|
|
|
func (m *AutoSwitchManager) Evaluate(win Window, currentPage string) (page string, shouldSwitch bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if win.WMClass == "" && win.Title == "" {
|
|
if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay {
|
|
m.autoPage = ""
|
|
slog.Debug("auto-switch: no focused window, reverting to manual page", "page", m.lastManualPage)
|
|
return m.lastManualPage, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
for _, r := range m.rules {
|
|
if r.wmClass == nil && r.title == nil {
|
|
continue
|
|
}
|
|
if r.wmClass != nil && !r.wmClass.MatchString(win.WMClass) {
|
|
continue
|
|
}
|
|
if r.title != nil && !r.title.MatchString(win.Title) {
|
|
continue
|
|
}
|
|
|
|
m.autoPage = r.rule.Page
|
|
m.autoStay = r.rule.Stay
|
|
slog.Debug("auto-switch: rule matched",
|
|
"wm_class", win.WMClass, "page", r.rule.Page, "stay", r.rule.Stay)
|
|
return r.rule.Page, true
|
|
}
|
|
|
|
if m.autoPage != "" && m.autoPage == currentPage && !m.autoStay {
|
|
m.autoPage = ""
|
|
slog.Debug("auto-switch: reverting to manual page", "page", m.lastManualPage)
|
|
return m.lastManualPage, true
|
|
}
|
|
|
|
return "", false
|
|
}
|